| 1 | #!/usr/bin/env python3 |
| 2 | """Internal helper: extract lightweight template assets and style metadata from a PPTX file. |
| 3 | |
| 4 | This helper is intentionally limited in scope: |
| 5 | - extract reusable media assets |
| 6 | - summarize slide size, theme colors, and fonts |
| 7 | - infer common background assets through slide/layout/master inheritance |
| 8 | - produce a compact manifest for downstream template reconstruction |
| 9 | |
| 10 | It does NOT try to convert arbitrary PPTX shapes into SVG templates. |
| 11 | |
| 12 | Output contract (single source of truth): |
| 13 | <workspace>/manifest.json — all factual metadata (theme, assets, slides, layouts, masters) |
| 14 | <workspace>/assets/ — extracted reusable image assets |
| 15 | |
| 16 | This module is a pure library. The CLI entry point lives in |
| 17 | ``pptx_template_import.py`` at the scripts root. |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import json |
| 23 | import posixpath |
| 24 | import re |
| 25 | import shutil |
| 26 | import zipfile |
| 27 | from collections import Counter, defaultdict |
| 28 | from dataclasses import dataclass |
| 29 | from pathlib import Path, PurePosixPath |
| 30 | from typing import Any |
| 31 | from xml.etree import ElementTree as ET |
| 32 | |
| 33 | from pptx_to_svg.ooxml_loader import ( |
| 34 | blip_embed_relationship_ids, |
| 35 | parse_ooxml_boolean, |
| 36 | ) |
| 37 | |
| 38 | |
| 39 | NS = { |
| 40 | "a": "http://schemas.openxmlformats.org/drawingml/2006/main", |
| 41 | "p": "http://schemas.openxmlformats.org/presentationml/2006/main", |
| 42 | "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", |
| 43 | "rel": "http://schemas.openxmlformats.org/package/2006/relationships", |
| 44 | } |
| 45 | |
| 46 | EMU_PER_INCH = 914400 |
| 47 | |
| 48 | SLIDE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" |
| 49 | LAYOUT_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" |
| 50 | MASTER_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" |
| 51 | THEME_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" |
| 52 | IMAGE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" |
| 53 | |
| 54 | THANKS_KEYWORDS = ("thank", "thanks", "q&a", "qa", "contact", "致谢", "谢谢", "感谢", "答疑", "联系方式") |
| 55 | TOC_KEYWORDS = ("agenda", "contents", "content", "outline", "目录", "议程", "目录页") |
| 56 | CHAPTER_KEYWORDS = ("chapter", "part", "section", "章节", "部分") |
| 57 | |
| 58 | |
| 59 | @dataclass |
| 60 | class SlideRecord: |
| 61 | index: int |
| 62 | name: str |
| 63 | slide_path: str |
| 64 | layout_path: str | None |
| 65 | master_path: str | None |
| 66 | show_inherited_shapes: bool |
| 67 | background_asset: str | None |
| 68 | background_source: str | None |
| 69 | image_assets: list[str] |
| 70 | text_samples: list[str] |
| 71 | text_count: int |
| 72 | shape_count: int |
| 73 | placeholders: list[dict[str, Any]] |
| 74 | page_type: str |
| 75 | svg_file: str |
| 76 | flat_svg_file: str | None |
| 77 | |
| 78 | |
| 79 | def summarize_part_record( |
| 80 | *, |
| 81 | part_path: str | None, |
| 82 | root: ET.Element | None, |
| 83 | rels: dict[str, dict[str, str]], |
| 84 | copied_assets: dict[str, str], |
| 85 | used_by_slides: list[int], |
| 86 | parent_path: str | None = None, |
| 87 | theme_path: str | None = None, |
| 88 | svg_file: str | None = None, |
| 89 | theme: dict[str, Any] | None = None, |
| 90 | ) -> dict[str, Any] | None: |
| 91 | if not part_path: |
| 92 | return None |
| 93 | |
| 94 | bg_asset = detect_background_asset(root, rels) |
| 95 | image_targets = extract_image_targets(root, rels) |
| 96 | sp_tree = root.find("p:cSld/p:spTree", NS) if root is not None else None |
| 97 | shape_image_targets = extract_image_targets(sp_tree, rels) |
| 98 | display_name = part_display_name(root, part_path) |
| 99 | layout_type = root.attrib.get("type") if root is not None else None |
| 100 | record = { |
| 101 | "path": part_path, |
| 102 | "name": PurePosixPath(part_path).name, |
| 103 | "displayName": display_name, |
| 104 | "layoutType": layout_type, |
| 105 | "svgFile": svg_file, |
| 106 | "parentPath": parent_path, |
| 107 | "themePath": theme_path, |
| 108 | "theme": theme, |
| 109 | "backgroundAsset": copied_assets.get(bg_asset, PurePosixPath(bg_asset).name if bg_asset else None), |
| 110 | "imageAssets": [copied_assets.get(target, PurePosixPath(target).name) for target in image_targets], |
| 111 | "shapeImageAssets": [ |
| 112 | copied_assets.get(target, PurePosixPath(target).name) |
| 113 | for target in shape_image_targets |
| 114 | ], |
| 115 | "placeholders": extract_placeholders(root), |
| 116 | "textSamples": extract_text_samples(root), |
| 117 | "textCount": len(root.findall(".//a:t", NS)) if root is not None else 0, |
| 118 | "shapeCount": count_slide_shapes(root), |
| 119 | "drawableShapeCount": count_drawable_shapes(root), |
| 120 | "usedBySlides": used_by_slides, |
| 121 | } |
| 122 | if root is not None and root.tag == f"{{{NS['p']}}}sldLayout": |
| 123 | record["showMasterShapes"] = parse_ooxml_boolean( |
| 124 | root.attrib.get("showMasterSp"), |
| 125 | default=True, |
| 126 | context=f"{part_path} showMasterSp", |
| 127 | ) |
| 128 | return record |
| 129 | |
| 130 | |
| 131 | def normalize_part(path: str, base: str | None = None) -> str: |
| 132 | if base: |
| 133 | path = str(PurePosixPath(base).parent.joinpath(path)) |
| 134 | path = path.replace("\\", "/") |
| 135 | normalized = posixpath.normpath(path) |
| 136 | if normalized.startswith("./"): |
| 137 | normalized = normalized[2:] |
| 138 | return normalized.lstrip("/") |
| 139 | |
| 140 | |
| 141 | def rels_path_for(part_path: str) -> str: |
| 142 | part = PurePosixPath(part_path) |
| 143 | return str(part.parent / "_rels" / f"{part.name}.rels") |
| 144 | |
| 145 | |
| 146 | def load_xml_from_zip(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None: |
| 147 | try: |
| 148 | with zf.open(part_path) as fh: |
| 149 | return ET.parse(fh).getroot() |
| 150 | except KeyError: |
| 151 | return None |
| 152 | except ET.ParseError: |
| 153 | return None |
| 154 | |
| 155 | |
| 156 | def parse_relationships(zf: zipfile.ZipFile, part_path: str) -> dict[str, dict[str, str]]: |
| 157 | rels_root = load_xml_from_zip(zf, rels_path_for(part_path)) |
| 158 | if rels_root is None: |
| 159 | return {} |
| 160 | |
| 161 | rels: dict[str, dict[str, str]] = {} |
| 162 | for rel in rels_root.findall("rel:Relationship", NS): |
| 163 | rel_id = rel.attrib.get("Id") |
| 164 | target = rel.attrib.get("Target") |
| 165 | rel_type = rel.attrib.get("Type") |
| 166 | if not rel_id or not target or not rel_type: |
| 167 | continue |
| 168 | rels[rel_id] = { |
| 169 | "type": rel_type, |
| 170 | "target": normalize_part(target, part_path), |
| 171 | } |
| 172 | return rels |
| 173 | |
| 174 | |
| 175 | def emu_to_pixels(value: int) -> int: |
| 176 | # PowerPoint uses 96 dpi; enough for summary output. |
| 177 | return int(round(value / EMU_PER_INCH * 96)) |
| 178 | |
| 179 | |
| 180 | def sanitize_filename(value: str) -> str: |
| 181 | value = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()) |
| 182 | return value.strip("._") or "asset" |
| 183 | |
| 184 | |
| 185 | def part_svg_filename(role: str, seq: int, part_path: str) -> str: |
| 186 | stem = PurePosixPath(part_path).stem |
| 187 | safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role |
| 188 | return f"{role}_{seq:02d}_{safe_stem}.svg" |
| 189 | |
| 190 | |
| 191 | def slide_svg_filename(index: int) -> str: |
| 192 | return f"slide_{index:02d}.svg" |
| 193 | |
| 194 | |
| 195 | def resolve_first_rel( |
| 196 | rels: dict[str, dict[str, str]], |
| 197 | rel_type: str, |
| 198 | ) -> str | None: |
| 199 | for rel in rels.values(): |
| 200 | if rel["type"] == rel_type: |
| 201 | return rel["target"] |
| 202 | return None |
| 203 | |
| 204 | |
| 205 | def parse_xfrm_record(sp: ET.Element) -> dict[str, int] | None: |
| 206 | xfrm = sp.find("p:spPr/a:xfrm", NS) |
| 207 | if xfrm is None: |
| 208 | return None |
| 209 | off = xfrm.find("a:off", NS) |
| 210 | ext = xfrm.find("a:ext", NS) |
| 211 | if off is None or ext is None: |
| 212 | return None |
| 213 | try: |
| 214 | x = int(off.attrib.get("x", "0")) |
| 215 | y = int(off.attrib.get("y", "0")) |
| 216 | w = int(ext.attrib.get("cx", "0")) |
| 217 | h = int(ext.attrib.get("cy", "0")) |
| 218 | except ValueError: |
| 219 | return None |
| 220 | return { |
| 221 | "x": emu_to_pixels(x), |
| 222 | "y": emu_to_pixels(y), |
| 223 | "width": emu_to_pixels(w), |
| 224 | "height": emu_to_pixels(h), |
| 225 | } |
| 226 | |
| 227 | |
| 228 | def part_display_name(root: ET.Element | None, part_path: str) -> str: |
| 229 | """Return the PowerPoint picker name, falling back to the package stem.""" |
| 230 | if root is not None: |
| 231 | common_slide = root.find("p:cSld", NS) |
| 232 | if common_slide is not None: |
| 233 | name = (common_slide.attrib.get("name") or "").strip() |
| 234 | if name: |
| 235 | return name |
| 236 | matching_name = (root.attrib.get("matchingName") or "").strip() |
| 237 | if matching_name: |
| 238 | return matching_name |
| 239 | return PurePosixPath(part_path).stem |
| 240 | |
| 241 | |
| 242 | def placeholder_semantic_role(placeholder_type: str | None) -> str: |
| 243 | """Map an OOXML placeholder type to the exporter role vocabulary.""" |
| 244 | normalized = placeholder_type or "obj" |
| 245 | role_by_type = { |
| 246 | "title": "title", |
| 247 | "ctrTitle": "title", |
| 248 | "body": "body", |
| 249 | "subTitle": "subtitle", |
| 250 | "obj": "object", |
| 251 | "pic": "picture", |
| 252 | "chart": "chart", |
| 253 | "tbl": "table", |
| 254 | "media": "media", |
| 255 | "dt": "date", |
| 256 | "ftr": "footer", |
| 257 | "sldNum": "slide-number", |
| 258 | } |
| 259 | return role_by_type.get(normalized, "other") |
| 260 | |
| 261 | |
| 262 | def extract_placeholders(root: ET.Element | None) -> list[dict[str, Any]]: |
| 263 | if root is None: |
| 264 | return [] |
| 265 | placeholders: list[dict[str, Any]] = [] |
| 266 | for sp in root.findall(".//p:sp", NS): |
| 267 | ph = sp.find("p:nvSpPr/p:nvPr/p:ph", NS) |
| 268 | if ph is None: |
| 269 | continue |
| 270 | non_visual = sp.find("p:nvSpPr/p:cNvPr", NS) |
| 271 | placeholder_type = ph.attrib.get("type") |
| 272 | record: dict[str, Any] = { |
| 273 | "type": placeholder_type, |
| 274 | "idx": ph.attrib.get("idx"), |
| 275 | "size": ph.attrib.get("sz"), |
| 276 | "orient": ph.attrib.get("orient"), |
| 277 | "semanticRole": placeholder_semantic_role(placeholder_type), |
| 278 | "shapeId": non_visual.attrib.get("id") if non_visual is not None else None, |
| 279 | "shapeName": non_visual.attrib.get("name") if non_visual is not None else None, |
| 280 | "geometry": parse_xfrm_record(sp), |
| 281 | "textSamples": extract_text_samples(sp, limit=2), |
| 282 | } |
| 283 | style = extract_placeholder_text_style(sp) |
| 284 | if style: |
| 285 | record["textStyle"] = style |
| 286 | placeholders.append(record) |
| 287 | return placeholders |
| 288 | |
| 289 | |
| 290 | def count_drawable_shapes(root: ET.Element | None) -> int: |
| 291 | """Count top-level visual shapes that are not placeholder definitions.""" |
| 292 | if root is None: |
| 293 | return 0 |
| 294 | sp_tree = root.find("p:cSld/p:spTree", NS) |
| 295 | if sp_tree is None: |
| 296 | return 0 |
| 297 | visual_tags = { |
| 298 | f"{{{NS['p']}}}sp", |
| 299 | f"{{{NS['p']}}}grpSp", |
| 300 | f"{{{NS['p']}}}graphicFrame", |
| 301 | f"{{{NS['p']}}}pic", |
| 302 | f"{{{NS['p']}}}cxnSp", |
| 303 | } |
| 304 | count = 0 |
| 305 | for child in sp_tree: |
| 306 | if child.tag not in visual_tags: |
| 307 | continue |
| 308 | if child.find("p:nvSpPr/p:nvPr/p:ph", NS) is not None: |
| 309 | continue |
| 310 | count += 1 |
| 311 | return count |
| 312 | |
| 313 | |
| 314 | def extract_placeholder_text_style(sp: ET.Element) -> dict[str, Any]: |
| 315 | style: dict[str, Any] = {} |
| 316 | rpr = sp.find(".//a:rPr", NS) |
| 317 | if rpr is None: |
| 318 | rpr = sp.find(".//a:endParaRPr", NS) |
| 319 | if rpr is None: |
| 320 | return style |
| 321 | if rpr.attrib.get("sz"): |
| 322 | try: |
| 323 | style["fontSizePx"] = round(int(rpr.attrib["sz"]) / 75, 2) |
| 324 | except ValueError: |
| 325 | pass |
| 326 | if rpr.attrib.get("b") == "1": |
| 327 | style["bold"] = True |
| 328 | if rpr.attrib.get("i") == "1": |
| 329 | style["italic"] = True |
| 330 | latin = rpr.find("a:latin", NS) |
| 331 | ea = rpr.find("a:ea", NS) |
| 332 | if latin is not None and latin.attrib.get("typeface"): |
| 333 | style["latinFont"] = latin.attrib["typeface"] |
| 334 | if ea is not None and ea.attrib.get("typeface"): |
| 335 | style["eastAsiaFont"] = ea.attrib["typeface"] |
| 336 | color = rpr.find("a:solidFill/a:srgbClr", NS) |
| 337 | if color is not None and color.attrib.get("val"): |
| 338 | style["fill"] = f"#{color.attrib['val']}" |
| 339 | return style |
| 340 | |
| 341 | |
| 342 | def extract_text_samples(root: ET.Element | None, limit: int = 6) -> list[str]: |
| 343 | if root is None: |
| 344 | return [] |
| 345 | samples: list[str] = [] |
| 346 | for node in root.findall(".//a:t", NS): |
| 347 | text = (node.text or "").strip() |
| 348 | if not text: |
| 349 | continue |
| 350 | samples.append(text) |
| 351 | if len(samples) >= limit: |
| 352 | break |
| 353 | return samples |
| 354 | |
| 355 | |
| 356 | def extract_image_targets(root: ET.Element | None, rels: dict[str, dict[str, str]]) -> list[str]: |
| 357 | if root is None: |
| 358 | return [] |
| 359 | targets: list[str] = [] |
| 360 | seen: set[str] = set() |
| 361 | for blip in root.findall(".//a:blip", NS): |
| 362 | target = _preferred_image_target(blip, rels) |
| 363 | if target is None or target in seen: |
| 364 | continue |
| 365 | seen.add(target) |
| 366 | targets.append(target) |
| 367 | return targets |
| 368 | |
| 369 | |
| 370 | def _preferred_image_target( |
| 371 | blip: ET.Element, |
| 372 | rels: dict[str, dict[str, str]], |
| 373 | ) -> str | None: |
| 374 | """Resolve an Office SVG relationship before its raster fallback.""" |
| 375 | for rel_id in blip_embed_relationship_ids(blip): |
| 376 | rel = rels.get(rel_id) |
| 377 | if rel and rel["type"] == IMAGE_REL: |
| 378 | return rel["target"] |
| 379 | return None |
| 380 | |
| 381 | |
| 382 | def detect_background_asset(root: ET.Element | None, rels: dict[str, dict[str, str]]) -> str | None: |
| 383 | if root is None: |
| 384 | return None |
| 385 | |
| 386 | bg = root.find("p:cSld/p:bg", NS) |
| 387 | if bg is None: |
| 388 | bg = root.find("p:bg", NS) |
| 389 | if bg is None: |
| 390 | return None |
| 391 | |
| 392 | blip = bg.find(".//a:blip", NS) |
| 393 | if blip is None: |
| 394 | return None |
| 395 | |
| 396 | return _preferred_image_target(blip, rels) |
| 397 | |
| 398 | |
| 399 | def count_slide_shapes(root: ET.Element | None) -> int: |
| 400 | if root is None: |
| 401 | return 0 |
| 402 | sp_tree = root.find("p:cSld/p:spTree", NS) |
| 403 | if sp_tree is None: |
| 404 | return 0 |
| 405 | return len(list(sp_tree)) |
| 406 | |
| 407 | |
| 408 | def classify_slide(index: int, total: int, texts: list[str], image_count: int, shape_count: int) -> str: |
| 409 | joined = " ".join(texts).lower() |
| 410 | if any(keyword in joined for keyword in THANKS_KEYWORDS): |
| 411 | return "ending_candidate" |
| 412 | if any(keyword in joined for keyword in TOC_KEYWORDS): |
| 413 | return "toc_candidate" |
| 414 | if any(keyword in joined for keyword in CHAPTER_KEYWORDS): |
| 415 | return "chapter_candidate" |
| 416 | if index == 1 and image_count <= 3: |
| 417 | return "cover_candidate" |
| 418 | if index == total and len(texts) <= 6: |
| 419 | return "ending_candidate" |
| 420 | if len(texts) <= 3 and shape_count <= 12: |
| 421 | return "chapter_candidate" |
| 422 | return "content_candidate" |
| 423 | |
| 424 | |
| 425 | def parse_theme(root: ET.Element | None) -> dict[str, Any]: |
| 426 | if root is None: |
| 427 | return {"colors": {}, "fonts": {}} |
| 428 | |
| 429 | colors: dict[str, str] = {} |
| 430 | clr_scheme = root.find(".//a:clrScheme", NS) |
| 431 | if clr_scheme is not None: |
| 432 | for child in list(clr_scheme): |
| 433 | if not isinstance(child.tag, str): |
| 434 | continue |
| 435 | name = child.tag.split("}", 1)[-1] |
| 436 | srgb = child.find("a:srgbClr", NS) |
| 437 | sys_clr = child.find("a:sysClr", NS) |
| 438 | if srgb is not None and "val" in srgb.attrib: |
| 439 | colors[name] = f"#{srgb.attrib['val']}" |
| 440 | elif sys_clr is not None: |
| 441 | last = sys_clr.attrib.get("lastClr") |
| 442 | if last: |
| 443 | colors[name] = f"#{last}" |
| 444 | |
| 445 | fonts: dict[str, str] = {} |
| 446 | font_scheme = root.find(".//a:fontScheme", NS) |
| 447 | if font_scheme is not None: |
| 448 | major = font_scheme.find("a:majorFont", NS) |
| 449 | minor = font_scheme.find("a:minorFont", NS) |
| 450 | if major is not None: |
| 451 | latin = major.find("a:latin", NS) |
| 452 | if latin is not None and latin.attrib.get("typeface"): |
| 453 | fonts["majorLatin"] = latin.attrib["typeface"] |
| 454 | if minor is not None: |
| 455 | latin = minor.find("a:latin", NS) |
| 456 | if latin is not None and latin.attrib.get("typeface"): |
| 457 | fonts["minorLatin"] = latin.attrib["typeface"] |
| 458 | ea = minor.find("a:ea", NS) |
| 459 | if ea is not None and ea.attrib.get("typeface"): |
| 460 | fonts["minorEastAsia"] = ea.attrib["typeface"] |
| 461 | |
| 462 | return {"colors": colors, "fonts": fonts} |
| 463 | |
| 464 | |
| 465 | def choose_common_assets(asset_usage: Counter[str]) -> list[str]: |
| 466 | common = [asset for asset, count in asset_usage.items() if count > 1] |
| 467 | return sorted(common) |
| 468 | |
| 469 | |
| 470 | def _effective_inherited_image_assets( |
| 471 | *, |
| 472 | show_inherited_shapes: bool, |
| 473 | layout_record: dict[str, Any] | None, |
| 474 | master_record: dict[str, Any] | None, |
| 475 | ) -> set[str]: |
| 476 | """Return visible inherited shape images, excluding background assets.""" |
| 477 | if not show_inherited_shapes: |
| 478 | return set() |
| 479 | |
| 480 | def shape_images(record: dict[str, Any] | None) -> set[str]: |
| 481 | if record is None: |
| 482 | return set() |
| 483 | if "shapeImageAssets" in record: |
| 484 | return { |
| 485 | asset |
| 486 | for asset in record.get("shapeImageAssets", []) |
| 487 | if asset |
| 488 | } |
| 489 | background = record.get("backgroundAsset") |
| 490 | return { |
| 491 | asset |
| 492 | for asset in record.get("imageAssets", []) |
| 493 | if asset and asset != background |
| 494 | } |
| 495 | |
| 496 | assets = shape_images(layout_record) |
| 497 | if layout_record is None or layout_record.get("showMasterShapes", True): |
| 498 | assets.update(shape_images(master_record)) |
| 499 | return assets |
| 500 | |
| 501 | |
| 502 | def build_manifest( |
| 503 | pptx_path: Path, |
| 504 | output_dir: Path, |
| 505 | *, |
| 506 | include_flat_svg: bool = False, |
| 507 | ) -> dict[str, Any]: |
| 508 | with zipfile.ZipFile(pptx_path, "r") as zf: |
| 509 | presentation_root = load_xml_from_zip(zf, "ppt/presentation.xml") |
| 510 | if presentation_root is None: |
| 511 | raise RuntimeError("Invalid PPTX: missing ppt/presentation.xml") |
| 512 | |
| 513 | slide_size = {"width_emu": 0, "height_emu": 0, "width_px": 0, "height_px": 0} |
| 514 | sld_sz = presentation_root.find("p:sldSz", NS) |
| 515 | if sld_sz is not None: |
| 516 | width_emu = int(sld_sz.attrib.get("cx", "0")) |
| 517 | height_emu = int(sld_sz.attrib.get("cy", "0")) |
| 518 | slide_size = { |
| 519 | "width_emu": width_emu, |
| 520 | "height_emu": height_emu, |
| 521 | "width_px": emu_to_pixels(width_emu), |
| 522 | "height_px": emu_to_pixels(height_emu), |
| 523 | } |
| 524 | |
| 525 | presentation_rels = parse_relationships(zf, "ppt/presentation.xml") |
| 526 | slide_parts: list[str] = [] |
| 527 | for sld_id in presentation_root.findall("p:sldIdLst/p:sldId", NS): |
| 528 | rel_id = sld_id.attrib.get(f"{{{NS['r']}}}id") |
| 529 | rel = presentation_rels.get(rel_id or "") |
| 530 | if rel and rel["type"] == SLIDE_REL: |
| 531 | slide_parts.append(rel["target"]) |
| 532 | |
| 533 | master_parts: list[str] = [] |
| 534 | for master_id in presentation_root.findall("p:sldMasterIdLst/p:sldMasterId", NS): |
| 535 | rel_id = master_id.attrib.get(f"{{{NS['r']}}}id") |
| 536 | rel = presentation_rels.get(rel_id or "") |
| 537 | if rel and rel["type"] == MASTER_REL and rel["target"] not in master_parts: |
| 538 | master_parts.append(rel["target"]) |
| 539 | |
| 540 | master_roots: dict[str, ET.Element | None] = {} |
| 541 | master_rels_map: dict[str, dict[str, dict[str, str]]] = {} |
| 542 | master_theme_path: dict[str, str | None] = {} |
| 543 | layout_parts: list[str] = [] |
| 544 | layout_parent: dict[str, str | None] = {} |
| 545 | for master_path in master_parts: |
| 546 | master_root = load_xml_from_zip(zf, master_path) |
| 547 | master_rels = parse_relationships(zf, master_path) |
| 548 | master_roots[master_path] = master_root |
| 549 | master_rels_map[master_path] = master_rels |
| 550 | master_theme_path[master_path] = resolve_first_rel(master_rels, THEME_REL) |
| 551 | if master_root is None: |
| 552 | continue |
| 553 | for layout_id in master_root.findall("p:sldLayoutIdLst/p:sldLayoutId", NS): |
| 554 | rel_id = layout_id.attrib.get(f"{{{NS['r']}}}id") |
| 555 | rel = master_rels.get(rel_id or "") |
| 556 | if not rel or rel["type"] != LAYOUT_REL: |
| 557 | continue |
| 558 | layout_path = rel["target"] |
| 559 | if layout_path not in layout_parent: |
| 560 | layout_parent[layout_path] = master_path |
| 561 | layout_parts.append(layout_path) |
| 562 | |
| 563 | asset_dir = output_dir / "assets" |
| 564 | if asset_dir.exists(): |
| 565 | shutil.rmtree(asset_dir) |
| 566 | asset_dir.mkdir(parents=True, exist_ok=True) |
| 567 | |
| 568 | copied_assets: dict[str, str] = {} |
| 569 | for info in zf.infolist(): |
| 570 | if not info.filename.startswith("ppt/media/") or info.is_dir(): |
| 571 | continue |
| 572 | original_name = PurePosixPath(info.filename).name |
| 573 | safe_name = sanitize_filename(original_name) |
| 574 | destination = asset_dir / safe_name |
| 575 | stem = destination.stem |
| 576 | suffix = destination.suffix |
| 577 | counter = 2 |
| 578 | while destination.exists(): |
| 579 | destination = asset_dir / f"{stem}_{counter}{suffix}" |
| 580 | counter += 1 |
| 581 | with zf.open(info.filename) as src, open(destination, "wb") as dst: |
| 582 | shutil.copyfileobj(src, dst) |
| 583 | copied_assets[info.filename] = destination.name |
| 584 | |
| 585 | slide_records: list[SlideRecord] = [] |
| 586 | asset_usage: Counter[str] = Counter() |
| 587 | layout_usage: defaultdict[str, list[int]] = defaultdict(list) |
| 588 | master_usage: defaultdict[str, list[int]] = defaultdict(list) |
| 589 | layout_cache: dict[str, dict[str, Any]] = {} |
| 590 | master_cache: dict[str, dict[str, Any]] = {} |
| 591 | |
| 592 | theme_summary = {"colors": {}, "fonts": {}} |
| 593 | |
| 594 | for index, slide_path in enumerate(slide_parts, 1): |
| 595 | slide_root = load_xml_from_zip(zf, slide_path) |
| 596 | slide_rels = parse_relationships(zf, slide_path) |
| 597 | |
| 598 | layout_path = None |
| 599 | for rel in slide_rels.values(): |
| 600 | if rel["type"] == LAYOUT_REL: |
| 601 | layout_path = rel["target"] |
| 602 | break |
| 603 | |
| 604 | layout_root = load_xml_from_zip(zf, layout_path) if layout_path else None |
| 605 | layout_rels = parse_relationships(zf, layout_path) if layout_path else {} |
| 606 | |
| 607 | master_path = None |
| 608 | for rel in layout_rels.values(): |
| 609 | if rel["type"] == MASTER_REL: |
| 610 | master_path = rel["target"] |
| 611 | break |
| 612 | |
| 613 | master_root = load_xml_from_zip(zf, master_path) if master_path else None |
| 614 | master_rels = parse_relationships(zf, master_path) if master_path else {} |
| 615 | |
| 616 | theme_path = None |
| 617 | for rel in master_rels.values(): |
| 618 | if rel["type"] == THEME_REL: |
| 619 | theme_path = rel["target"] |
| 620 | break |
| 621 | if theme_path and not theme_summary["colors"] and not theme_summary["fonts"]: |
| 622 | theme_summary = parse_theme(load_xml_from_zip(zf, theme_path)) |
| 623 | |
| 624 | bg_asset = None |
| 625 | bg_source = None |
| 626 | for label, root, rels in ( |
| 627 | ("slide", slide_root, slide_rels), |
| 628 | ("layout", layout_root, layout_rels), |
| 629 | ("master", master_root, master_rels), |
| 630 | ): |
| 631 | candidate = detect_background_asset(root, rels) |
| 632 | if candidate: |
| 633 | bg_asset = candidate |
| 634 | bg_source = label |
| 635 | break |
| 636 | |
| 637 | image_targets = extract_image_targets(slide_root, slide_rels) |
| 638 | texts = extract_text_samples(slide_root) |
| 639 | shape_count = count_slide_shapes(slide_root) |
| 640 | placeholders = extract_placeholders(slide_root) |
| 641 | page_type = classify_slide(index, len(slide_parts), texts, len(image_targets), shape_count) |
| 642 | |
| 643 | resolved_bg = copied_assets.get(bg_asset, PurePosixPath(bg_asset).name if bg_asset else None) |
| 644 | resolved_images = [ |
| 645 | copied_assets.get(target, PurePosixPath(target).name) |
| 646 | for target in image_targets |
| 647 | ] |
| 648 | |
| 649 | if resolved_bg: |
| 650 | asset_usage[resolved_bg] += 1 |
| 651 | for asset_name in resolved_images: |
| 652 | asset_usage[asset_name] += 1 |
| 653 | |
| 654 | if layout_path: |
| 655 | if layout_path not in layout_parent: |
| 656 | layout_parent[layout_path] = master_path |
| 657 | layout_parts.append(layout_path) |
| 658 | layout_usage[layout_path].append(index) |
| 659 | if layout_path not in layout_cache: |
| 660 | layout_cache[layout_path] = { |
| 661 | "root": layout_root, |
| 662 | "rels": layout_rels, |
| 663 | "master_path": master_path, |
| 664 | } |
| 665 | if master_path: |
| 666 | if master_path not in master_parts: |
| 667 | master_parts.append(master_path) |
| 668 | master_roots[master_path] = master_root |
| 669 | master_rels_map[master_path] = master_rels |
| 670 | master_theme_path[master_path] = theme_path |
| 671 | master_usage[master_path].append(index) |
| 672 | if master_path not in master_cache: |
| 673 | master_cache[master_path] = { |
| 674 | "root": master_root, |
| 675 | "rels": master_rels, |
| 676 | "theme_path": theme_path, |
| 677 | } |
| 678 | |
| 679 | slide_records.append( |
| 680 | SlideRecord( |
| 681 | index=index, |
| 682 | name=PurePosixPath(slide_path).name, |
| 683 | slide_path=slide_path, |
| 684 | layout_path=layout_path, |
| 685 | master_path=master_path, |
| 686 | show_inherited_shapes=parse_ooxml_boolean( |
| 687 | slide_root.attrib.get("showMasterSp") |
| 688 | if slide_root is not None else None, |
| 689 | default=True, |
| 690 | context=f"{slide_path} showMasterSp", |
| 691 | ), |
| 692 | background_asset=resolved_bg, |
| 693 | background_source=bg_source, |
| 694 | image_assets=resolved_images, |
| 695 | text_samples=texts, |
| 696 | text_count=len(texts), |
| 697 | shape_count=shape_count, |
| 698 | placeholders=placeholders, |
| 699 | page_type=page_type, |
| 700 | svg_file=slide_svg_filename(index), |
| 701 | flat_svg_file=( |
| 702 | slide_svg_filename(index) if include_flat_svg else None |
| 703 | ), |
| 704 | ) |
| 705 | ) |
| 706 | |
| 707 | for layout_path in layout_parts: |
| 708 | if layout_path in layout_cache: |
| 709 | continue |
| 710 | layout_root = load_xml_from_zip(zf, layout_path) |
| 711 | layout_rels = parse_relationships(zf, layout_path) |
| 712 | layout_cache[layout_path] = { |
| 713 | "root": layout_root, |
| 714 | "rels": layout_rels, |
| 715 | "master_path": layout_parent.get(layout_path), |
| 716 | } |
| 717 | |
| 718 | for master_path in master_parts: |
| 719 | if master_path in master_cache: |
| 720 | continue |
| 721 | master_cache[master_path] = { |
| 722 | "root": master_roots.get(master_path), |
| 723 | "rels": master_rels_map.get(master_path, {}), |
| 724 | "theme_path": master_theme_path.get(master_path), |
| 725 | } |
| 726 | |
| 727 | page_type_map: dict[str, list[int]] = defaultdict(list) |
| 728 | for slide in slide_records: |
| 729 | page_type_map[slide.page_type].append(slide.index) |
| 730 | |
| 731 | layout_records = [ |
| 732 | summarize_part_record( |
| 733 | part_path=layout_path, |
| 734 | root=layout_cache[layout_path]["root"], |
| 735 | rels=layout_cache[layout_path]["rels"], |
| 736 | copied_assets=copied_assets, |
| 737 | used_by_slides=layout_usage[layout_path], |
| 738 | parent_path=layout_cache[layout_path]["master_path"], |
| 739 | svg_file=part_svg_filename("layout", seq, layout_path), |
| 740 | ) |
| 741 | for seq, layout_path in enumerate(layout_parts, start=1) |
| 742 | if layout_path in layout_cache |
| 743 | ] |
| 744 | master_records = [ |
| 745 | summarize_part_record( |
| 746 | part_path=master_path, |
| 747 | root=master_cache[master_path]["root"], |
| 748 | rels=master_cache[master_path]["rels"], |
| 749 | copied_assets=copied_assets, |
| 750 | used_by_slides=master_usage[master_path], |
| 751 | theme_path=master_cache[master_path]["theme_path"], |
| 752 | svg_file=part_svg_filename("master", seq, master_path), |
| 753 | theme=parse_theme(load_xml_from_zip(zf, master_cache[master_path]["theme_path"])) |
| 754 | if master_cache[master_path]["theme_path"] else {"colors": {}, "fonts": {}}, |
| 755 | ) |
| 756 | for seq, master_path in enumerate(master_parts, start=1) |
| 757 | if master_path in master_cache |
| 758 | ] |
| 759 | layouts_top = [item for item in layout_records if item] |
| 760 | masters_top = [item for item in master_records if item] |
| 761 | |
| 762 | layout_by_path = {item["path"]: item for item in layouts_top} |
| 763 | master_by_path = {item["path"]: item for item in masters_top} |
| 764 | asset_usage = Counter() |
| 765 | for slide in slide_records: |
| 766 | per_slide_assets: set[str] = set(slide.image_assets) |
| 767 | if slide.background_asset: |
| 768 | per_slide_assets.add(slide.background_asset) |
| 769 | layout_record = layout_by_path.get(slide.layout_path or "") |
| 770 | master_record = master_by_path.get(slide.master_path or "") |
| 771 | per_slide_assets.update(_effective_inherited_image_assets( |
| 772 | show_inherited_shapes=slide.show_inherited_shapes, |
| 773 | layout_record=layout_record, |
| 774 | master_record=master_record, |
| 775 | )) |
| 776 | for asset in per_slide_assets: |
| 777 | if asset: |
| 778 | asset_usage[asset] += 1 |
| 779 | |
| 780 | common_assets = choose_common_assets(asset_usage) |
| 781 | if not theme_summary["colors"] and not theme_summary["fonts"] and masters_top: |
| 782 | theme_summary = masters_top[0].get("theme") or {"colors": {}, "fonts": {}} |
| 783 | |
| 784 | manifest = { |
| 785 | "source": { |
| 786 | "pptx": str(pptx_path), |
| 787 | "name": pptx_path.name, |
| 788 | }, |
| 789 | "slideSize": slide_size, |
| 790 | "theme": theme_summary, |
| 791 | "assets": { |
| 792 | "exportDir": "assets", |
| 793 | "commonAssets": common_assets, |
| 794 | "allAssets": sorted(copied_assets.values()), |
| 795 | "assetMap": copied_assets, |
| 796 | }, |
| 797 | "pageTypeCandidates": dict(sorted(page_type_map.items())), |
| 798 | "layouts": layouts_top, |
| 799 | "masters": masters_top, |
| 800 | "slides": [ |
| 801 | { |
| 802 | "index": slide.index, |
| 803 | "name": slide.name, |
| 804 | "svgFile": slide.svg_file, |
| 805 | "flatSvgFile": slide.flat_svg_file, |
| 806 | "slidePath": slide.slide_path, |
| 807 | "layoutPath": slide.layout_path, |
| 808 | "masterPath": slide.master_path, |
| 809 | "showInheritedShapes": slide.show_inherited_shapes, |
| 810 | "backgroundAsset": slide.background_asset, |
| 811 | "backgroundSource": slide.background_source, |
| 812 | "imageAssets": slide.image_assets, |
| 813 | "textSamples": slide.text_samples, |
| 814 | "textCount": slide.text_count, |
| 815 | "shapeCount": slide.shape_count, |
| 816 | "placeholders": slide.placeholders, |
| 817 | "pageType": slide.page_type, |
| 818 | } |
| 819 | for slide in slide_records |
| 820 | ], |
| 821 | } |
| 822 | |
| 823 | return manifest |
| 824 |