返回 ppt-master
manifest.py
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
824 lines PYTHON