Files
Templated-PPT-Master/runtime/ppt-master/scripts/template_import/manifest.py
T

824 lines
30 KiB
Python

#!/usr/bin/env python3
"""Internal helper: extract lightweight template assets and style metadata from a PPTX file.
This helper is intentionally limited in scope:
- extract reusable media assets
- summarize slide size, theme colors, and fonts
- infer common background assets through slide/layout/master inheritance
- produce a compact manifest for downstream template reconstruction
It does NOT try to convert arbitrary PPTX shapes into SVG templates.
Output contract (single source of truth):
<workspace>/manifest.json — all factual metadata (theme, assets, slides, layouts, masters)
<workspace>/assets/ — extracted reusable image assets
This module is a pure library. The CLI entry point lives in
``pptx_template_import.py`` at the scripts root.
"""
from __future__ import annotations
import json
import posixpath
import re
import shutil
import zipfile
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from xml.etree import ElementTree as ET
from pptx_to_svg.ooxml_loader import (
blip_embed_relationship_ids,
parse_ooxml_boolean,
)
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
}
EMU_PER_INCH = 914400
SLIDE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
LAYOUT_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
MASTER_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
THEME_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
IMAGE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
THANKS_KEYWORDS = ("thank", "thanks", "q&a", "qa", "contact", "致谢", "谢谢", "感谢", "答疑", "联系方式")
TOC_KEYWORDS = ("agenda", "contents", "content", "outline", "目录", "议程", "目录页")
CHAPTER_KEYWORDS = ("chapter", "part", "section", "章节", "部分")
@dataclass
class SlideRecord:
index: int
name: str
slide_path: str
layout_path: str | None
master_path: str | None
show_inherited_shapes: bool
background_asset: str | None
background_source: str | None
image_assets: list[str]
text_samples: list[str]
text_count: int
shape_count: int
placeholders: list[dict[str, Any]]
page_type: str
svg_file: str
flat_svg_file: str | None
def summarize_part_record(
*,
part_path: str | None,
root: ET.Element | None,
rels: dict[str, dict[str, str]],
copied_assets: dict[str, str],
used_by_slides: list[int],
parent_path: str | None = None,
theme_path: str | None = None,
svg_file: str | None = None,
theme: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if not part_path:
return None
bg_asset = detect_background_asset(root, rels)
image_targets = extract_image_targets(root, rels)
sp_tree = root.find("p:cSld/p:spTree", NS) if root is not None else None
shape_image_targets = extract_image_targets(sp_tree, rels)
display_name = part_display_name(root, part_path)
layout_type = root.attrib.get("type") if root is not None else None
record = {
"path": part_path,
"name": PurePosixPath(part_path).name,
"displayName": display_name,
"layoutType": layout_type,
"svgFile": svg_file,
"parentPath": parent_path,
"themePath": theme_path,
"theme": theme,
"backgroundAsset": copied_assets.get(bg_asset, PurePosixPath(bg_asset).name if bg_asset else None),
"imageAssets": [copied_assets.get(target, PurePosixPath(target).name) for target in image_targets],
"shapeImageAssets": [
copied_assets.get(target, PurePosixPath(target).name)
for target in shape_image_targets
],
"placeholders": extract_placeholders(root),
"textSamples": extract_text_samples(root),
"textCount": len(root.findall(".//a:t", NS)) if root is not None else 0,
"shapeCount": count_slide_shapes(root),
"drawableShapeCount": count_drawable_shapes(root),
"usedBySlides": used_by_slides,
}
if root is not None and root.tag == f"{{{NS['p']}}}sldLayout":
record["showMasterShapes"] = parse_ooxml_boolean(
root.attrib.get("showMasterSp"),
default=True,
context=f"{part_path} showMasterSp",
)
return record
def normalize_part(path: str, base: str | None = None) -> str:
if base:
path = str(PurePosixPath(base).parent.joinpath(path))
path = path.replace("\\", "/")
normalized = posixpath.normpath(path)
if normalized.startswith("./"):
normalized = normalized[2:]
return normalized.lstrip("/")
def rels_path_for(part_path: str) -> str:
part = PurePosixPath(part_path)
return str(part.parent / "_rels" / f"{part.name}.rels")
def load_xml_from_zip(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None:
try:
with zf.open(part_path) as fh:
return ET.parse(fh).getroot()
except KeyError:
return None
except ET.ParseError:
return None
def parse_relationships(zf: zipfile.ZipFile, part_path: str) -> dict[str, dict[str, str]]:
rels_root = load_xml_from_zip(zf, rels_path_for(part_path))
if rels_root is None:
return {}
rels: dict[str, dict[str, str]] = {}
for rel in rels_root.findall("rel:Relationship", NS):
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
rel_type = rel.attrib.get("Type")
if not rel_id or not target or not rel_type:
continue
rels[rel_id] = {
"type": rel_type,
"target": normalize_part(target, part_path),
}
return rels
def emu_to_pixels(value: int) -> int:
# PowerPoint uses 96 dpi; enough for summary output.
return int(round(value / EMU_PER_INCH * 96))
def sanitize_filename(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip())
return value.strip("._") or "asset"
def part_svg_filename(role: str, seq: int, part_path: str) -> str:
stem = PurePosixPath(part_path).stem
safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role
return f"{role}_{seq:02d}_{safe_stem}.svg"
def slide_svg_filename(index: int) -> str:
return f"slide_{index:02d}.svg"
def resolve_first_rel(
rels: dict[str, dict[str, str]],
rel_type: str,
) -> str | None:
for rel in rels.values():
if rel["type"] == rel_type:
return rel["target"]
return None
def parse_xfrm_record(sp: ET.Element) -> dict[str, int] | None:
xfrm = sp.find("p:spPr/a:xfrm", NS)
if xfrm is None:
return None
off = xfrm.find("a:off", NS)
ext = xfrm.find("a:ext", NS)
if off is None or ext is None:
return None
try:
x = int(off.attrib.get("x", "0"))
y = int(off.attrib.get("y", "0"))
w = int(ext.attrib.get("cx", "0"))
h = int(ext.attrib.get("cy", "0"))
except ValueError:
return None
return {
"x": emu_to_pixels(x),
"y": emu_to_pixels(y),
"width": emu_to_pixels(w),
"height": emu_to_pixels(h),
}
def part_display_name(root: ET.Element | None, part_path: str) -> str:
"""Return the PowerPoint picker name, falling back to the package stem."""
if root is not None:
common_slide = root.find("p:cSld", NS)
if common_slide is not None:
name = (common_slide.attrib.get("name") or "").strip()
if name:
return name
matching_name = (root.attrib.get("matchingName") or "").strip()
if matching_name:
return matching_name
return PurePosixPath(part_path).stem
def placeholder_semantic_role(placeholder_type: str | None) -> str:
"""Map an OOXML placeholder type to the exporter role vocabulary."""
normalized = placeholder_type or "obj"
role_by_type = {
"title": "title",
"ctrTitle": "title",
"body": "body",
"subTitle": "subtitle",
"obj": "object",
"pic": "picture",
"chart": "chart",
"tbl": "table",
"media": "media",
"dt": "date",
"ftr": "footer",
"sldNum": "slide-number",
}
return role_by_type.get(normalized, "other")
def extract_placeholders(root: ET.Element | None) -> list[dict[str, Any]]:
if root is None:
return []
placeholders: list[dict[str, Any]] = []
for sp in root.findall(".//p:sp", NS):
ph = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
if ph is None:
continue
non_visual = sp.find("p:nvSpPr/p:cNvPr", NS)
placeholder_type = ph.attrib.get("type")
record: dict[str, Any] = {
"type": placeholder_type,
"idx": ph.attrib.get("idx"),
"size": ph.attrib.get("sz"),
"orient": ph.attrib.get("orient"),
"semanticRole": placeholder_semantic_role(placeholder_type),
"shapeId": non_visual.attrib.get("id") if non_visual is not None else None,
"shapeName": non_visual.attrib.get("name") if non_visual is not None else None,
"geometry": parse_xfrm_record(sp),
"textSamples": extract_text_samples(sp, limit=2),
}
style = extract_placeholder_text_style(sp)
if style:
record["textStyle"] = style
placeholders.append(record)
return placeholders
def count_drawable_shapes(root: ET.Element | None) -> int:
"""Count top-level visual shapes that are not placeholder definitions."""
if root is None:
return 0
sp_tree = root.find("p:cSld/p:spTree", NS)
if sp_tree is None:
return 0
visual_tags = {
f"{{{NS['p']}}}sp",
f"{{{NS['p']}}}grpSp",
f"{{{NS['p']}}}graphicFrame",
f"{{{NS['p']}}}pic",
f"{{{NS['p']}}}cxnSp",
}
count = 0
for child in sp_tree:
if child.tag not in visual_tags:
continue
if child.find("p:nvSpPr/p:nvPr/p:ph", NS) is not None:
continue
count += 1
return count
def extract_placeholder_text_style(sp: ET.Element) -> dict[str, Any]:
style: dict[str, Any] = {}
rpr = sp.find(".//a:rPr", NS)
if rpr is None:
rpr = sp.find(".//a:endParaRPr", NS)
if rpr is None:
return style
if rpr.attrib.get("sz"):
try:
style["fontSizePx"] = round(int(rpr.attrib["sz"]) / 75, 2)
except ValueError:
pass
if rpr.attrib.get("b") == "1":
style["bold"] = True
if rpr.attrib.get("i") == "1":
style["italic"] = True
latin = rpr.find("a:latin", NS)
ea = rpr.find("a:ea", NS)
if latin is not None and latin.attrib.get("typeface"):
style["latinFont"] = latin.attrib["typeface"]
if ea is not None and ea.attrib.get("typeface"):
style["eastAsiaFont"] = ea.attrib["typeface"]
color = rpr.find("a:solidFill/a:srgbClr", NS)
if color is not None and color.attrib.get("val"):
style["fill"] = f"#{color.attrib['val']}"
return style
def extract_text_samples(root: ET.Element | None, limit: int = 6) -> list[str]:
if root is None:
return []
samples: list[str] = []
for node in root.findall(".//a:t", NS):
text = (node.text or "").strip()
if not text:
continue
samples.append(text)
if len(samples) >= limit:
break
return samples
def extract_image_targets(root: ET.Element | None, rels: dict[str, dict[str, str]]) -> list[str]:
if root is None:
return []
targets: list[str] = []
seen: set[str] = set()
for blip in root.findall(".//a:blip", NS):
target = _preferred_image_target(blip, rels)
if target is None or target in seen:
continue
seen.add(target)
targets.append(target)
return targets
def _preferred_image_target(
blip: ET.Element,
rels: dict[str, dict[str, str]],
) -> str | None:
"""Resolve an Office SVG relationship before its raster fallback."""
for rel_id in blip_embed_relationship_ids(blip):
rel = rels.get(rel_id)
if rel and rel["type"] == IMAGE_REL:
return rel["target"]
return None
def detect_background_asset(root: ET.Element | None, rels: dict[str, dict[str, str]]) -> str | None:
if root is None:
return None
bg = root.find("p:cSld/p:bg", NS)
if bg is None:
bg = root.find("p:bg", NS)
if bg is None:
return None
blip = bg.find(".//a:blip", NS)
if blip is None:
return None
return _preferred_image_target(blip, rels)
def count_slide_shapes(root: ET.Element | None) -> int:
if root is None:
return 0
sp_tree = root.find("p:cSld/p:spTree", NS)
if sp_tree is None:
return 0
return len(list(sp_tree))
def classify_slide(index: int, total: int, texts: list[str], image_count: int, shape_count: int) -> str:
joined = " ".join(texts).lower()
if any(keyword in joined for keyword in THANKS_KEYWORDS):
return "ending_candidate"
if any(keyword in joined for keyword in TOC_KEYWORDS):
return "toc_candidate"
if any(keyword in joined for keyword in CHAPTER_KEYWORDS):
return "chapter_candidate"
if index == 1 and image_count <= 3:
return "cover_candidate"
if index == total and len(texts) <= 6:
return "ending_candidate"
if len(texts) <= 3 and shape_count <= 12:
return "chapter_candidate"
return "content_candidate"
def parse_theme(root: ET.Element | None) -> dict[str, Any]:
if root is None:
return {"colors": {}, "fonts": {}}
colors: dict[str, str] = {}
clr_scheme = root.find(".//a:clrScheme", NS)
if clr_scheme is not None:
for child in list(clr_scheme):
if not isinstance(child.tag, str):
continue
name = child.tag.split("}", 1)[-1]
srgb = child.find("a:srgbClr", NS)
sys_clr = child.find("a:sysClr", NS)
if srgb is not None and "val" in srgb.attrib:
colors[name] = f"#{srgb.attrib['val']}"
elif sys_clr is not None:
last = sys_clr.attrib.get("lastClr")
if last:
colors[name] = f"#{last}"
fonts: dict[str, str] = {}
font_scheme = root.find(".//a:fontScheme", NS)
if font_scheme is not None:
major = font_scheme.find("a:majorFont", NS)
minor = font_scheme.find("a:minorFont", NS)
if major is not None:
latin = major.find("a:latin", NS)
if latin is not None and latin.attrib.get("typeface"):
fonts["majorLatin"] = latin.attrib["typeface"]
if minor is not None:
latin = minor.find("a:latin", NS)
if latin is not None and latin.attrib.get("typeface"):
fonts["minorLatin"] = latin.attrib["typeface"]
ea = minor.find("a:ea", NS)
if ea is not None and ea.attrib.get("typeface"):
fonts["minorEastAsia"] = ea.attrib["typeface"]
return {"colors": colors, "fonts": fonts}
def choose_common_assets(asset_usage: Counter[str]) -> list[str]:
common = [asset for asset, count in asset_usage.items() if count > 1]
return sorted(common)
def _effective_inherited_image_assets(
*,
show_inherited_shapes: bool,
layout_record: dict[str, Any] | None,
master_record: dict[str, Any] | None,
) -> set[str]:
"""Return visible inherited shape images, excluding background assets."""
if not show_inherited_shapes:
return set()
def shape_images(record: dict[str, Any] | None) -> set[str]:
if record is None:
return set()
if "shapeImageAssets" in record:
return {
asset
for asset in record.get("shapeImageAssets", [])
if asset
}
background = record.get("backgroundAsset")
return {
asset
for asset in record.get("imageAssets", [])
if asset and asset != background
}
assets = shape_images(layout_record)
if layout_record is None or layout_record.get("showMasterShapes", True):
assets.update(shape_images(master_record))
return assets
def build_manifest(
pptx_path: Path,
output_dir: Path,
*,
include_flat_svg: bool = False,
) -> dict[str, Any]:
with zipfile.ZipFile(pptx_path, "r") as zf:
presentation_root = load_xml_from_zip(zf, "ppt/presentation.xml")
if presentation_root is None:
raise RuntimeError("Invalid PPTX: missing ppt/presentation.xml")
slide_size = {"width_emu": 0, "height_emu": 0, "width_px": 0, "height_px": 0}
sld_sz = presentation_root.find("p:sldSz", NS)
if sld_sz is not None:
width_emu = int(sld_sz.attrib.get("cx", "0"))
height_emu = int(sld_sz.attrib.get("cy", "0"))
slide_size = {
"width_emu": width_emu,
"height_emu": height_emu,
"width_px": emu_to_pixels(width_emu),
"height_px": emu_to_pixels(height_emu),
}
presentation_rels = parse_relationships(zf, "ppt/presentation.xml")
slide_parts: list[str] = []
for sld_id in presentation_root.findall("p:sldIdLst/p:sldId", NS):
rel_id = sld_id.attrib.get(f"{{{NS['r']}}}id")
rel = presentation_rels.get(rel_id or "")
if rel and rel["type"] == SLIDE_REL:
slide_parts.append(rel["target"])
master_parts: list[str] = []
for master_id in presentation_root.findall("p:sldMasterIdLst/p:sldMasterId", NS):
rel_id = master_id.attrib.get(f"{{{NS['r']}}}id")
rel = presentation_rels.get(rel_id or "")
if rel and rel["type"] == MASTER_REL and rel["target"] not in master_parts:
master_parts.append(rel["target"])
master_roots: dict[str, ET.Element | None] = {}
master_rels_map: dict[str, dict[str, dict[str, str]]] = {}
master_theme_path: dict[str, str | None] = {}
layout_parts: list[str] = []
layout_parent: dict[str, str | None] = {}
for master_path in master_parts:
master_root = load_xml_from_zip(zf, master_path)
master_rels = parse_relationships(zf, master_path)
master_roots[master_path] = master_root
master_rels_map[master_path] = master_rels
master_theme_path[master_path] = resolve_first_rel(master_rels, THEME_REL)
if master_root is None:
continue
for layout_id in master_root.findall("p:sldLayoutIdLst/p:sldLayoutId", NS):
rel_id = layout_id.attrib.get(f"{{{NS['r']}}}id")
rel = master_rels.get(rel_id or "")
if not rel or rel["type"] != LAYOUT_REL:
continue
layout_path = rel["target"]
if layout_path not in layout_parent:
layout_parent[layout_path] = master_path
layout_parts.append(layout_path)
asset_dir = output_dir / "assets"
if asset_dir.exists():
shutil.rmtree(asset_dir)
asset_dir.mkdir(parents=True, exist_ok=True)
copied_assets: dict[str, str] = {}
for info in zf.infolist():
if not info.filename.startswith("ppt/media/") or info.is_dir():
continue
original_name = PurePosixPath(info.filename).name
safe_name = sanitize_filename(original_name)
destination = asset_dir / safe_name
stem = destination.stem
suffix = destination.suffix
counter = 2
while destination.exists():
destination = asset_dir / f"{stem}_{counter}{suffix}"
counter += 1
with zf.open(info.filename) as src, open(destination, "wb") as dst:
shutil.copyfileobj(src, dst)
copied_assets[info.filename] = destination.name
slide_records: list[SlideRecord] = []
asset_usage: Counter[str] = Counter()
layout_usage: defaultdict[str, list[int]] = defaultdict(list)
master_usage: defaultdict[str, list[int]] = defaultdict(list)
layout_cache: dict[str, dict[str, Any]] = {}
master_cache: dict[str, dict[str, Any]] = {}
theme_summary = {"colors": {}, "fonts": {}}
for index, slide_path in enumerate(slide_parts, 1):
slide_root = load_xml_from_zip(zf, slide_path)
slide_rels = parse_relationships(zf, slide_path)
layout_path = None
for rel in slide_rels.values():
if rel["type"] == LAYOUT_REL:
layout_path = rel["target"]
break
layout_root = load_xml_from_zip(zf, layout_path) if layout_path else None
layout_rels = parse_relationships(zf, layout_path) if layout_path else {}
master_path = None
for rel in layout_rels.values():
if rel["type"] == MASTER_REL:
master_path = rel["target"]
break
master_root = load_xml_from_zip(zf, master_path) if master_path else None
master_rels = parse_relationships(zf, master_path) if master_path else {}
theme_path = None
for rel in master_rels.values():
if rel["type"] == THEME_REL:
theme_path = rel["target"]
break
if theme_path and not theme_summary["colors"] and not theme_summary["fonts"]:
theme_summary = parse_theme(load_xml_from_zip(zf, theme_path))
bg_asset = None
bg_source = None
for label, root, rels in (
("slide", slide_root, slide_rels),
("layout", layout_root, layout_rels),
("master", master_root, master_rels),
):
candidate = detect_background_asset(root, rels)
if candidate:
bg_asset = candidate
bg_source = label
break
image_targets = extract_image_targets(slide_root, slide_rels)
texts = extract_text_samples(slide_root)
shape_count = count_slide_shapes(slide_root)
placeholders = extract_placeholders(slide_root)
page_type = classify_slide(index, len(slide_parts), texts, len(image_targets), shape_count)
resolved_bg = copied_assets.get(bg_asset, PurePosixPath(bg_asset).name if bg_asset else None)
resolved_images = [
copied_assets.get(target, PurePosixPath(target).name)
for target in image_targets
]
if resolved_bg:
asset_usage[resolved_bg] += 1
for asset_name in resolved_images:
asset_usage[asset_name] += 1
if layout_path:
if layout_path not in layout_parent:
layout_parent[layout_path] = master_path
layout_parts.append(layout_path)
layout_usage[layout_path].append(index)
if layout_path not in layout_cache:
layout_cache[layout_path] = {
"root": layout_root,
"rels": layout_rels,
"master_path": master_path,
}
if master_path:
if master_path not in master_parts:
master_parts.append(master_path)
master_roots[master_path] = master_root
master_rels_map[master_path] = master_rels
master_theme_path[master_path] = theme_path
master_usage[master_path].append(index)
if master_path not in master_cache:
master_cache[master_path] = {
"root": master_root,
"rels": master_rels,
"theme_path": theme_path,
}
slide_records.append(
SlideRecord(
index=index,
name=PurePosixPath(slide_path).name,
slide_path=slide_path,
layout_path=layout_path,
master_path=master_path,
show_inherited_shapes=parse_ooxml_boolean(
slide_root.attrib.get("showMasterSp")
if slide_root is not None else None,
default=True,
context=f"{slide_path} showMasterSp",
),
background_asset=resolved_bg,
background_source=bg_source,
image_assets=resolved_images,
text_samples=texts,
text_count=len(texts),
shape_count=shape_count,
placeholders=placeholders,
page_type=page_type,
svg_file=slide_svg_filename(index),
flat_svg_file=(
slide_svg_filename(index) if include_flat_svg else None
),
)
)
for layout_path in layout_parts:
if layout_path in layout_cache:
continue
layout_root = load_xml_from_zip(zf, layout_path)
layout_rels = parse_relationships(zf, layout_path)
layout_cache[layout_path] = {
"root": layout_root,
"rels": layout_rels,
"master_path": layout_parent.get(layout_path),
}
for master_path in master_parts:
if master_path in master_cache:
continue
master_cache[master_path] = {
"root": master_roots.get(master_path),
"rels": master_rels_map.get(master_path, {}),
"theme_path": master_theme_path.get(master_path),
}
page_type_map: dict[str, list[int]] = defaultdict(list)
for slide in slide_records:
page_type_map[slide.page_type].append(slide.index)
layout_records = [
summarize_part_record(
part_path=layout_path,
root=layout_cache[layout_path]["root"],
rels=layout_cache[layout_path]["rels"],
copied_assets=copied_assets,
used_by_slides=layout_usage[layout_path],
parent_path=layout_cache[layout_path]["master_path"],
svg_file=part_svg_filename("layout", seq, layout_path),
)
for seq, layout_path in enumerate(layout_parts, start=1)
if layout_path in layout_cache
]
master_records = [
summarize_part_record(
part_path=master_path,
root=master_cache[master_path]["root"],
rels=master_cache[master_path]["rels"],
copied_assets=copied_assets,
used_by_slides=master_usage[master_path],
theme_path=master_cache[master_path]["theme_path"],
svg_file=part_svg_filename("master", seq, master_path),
theme=parse_theme(load_xml_from_zip(zf, master_cache[master_path]["theme_path"]))
if master_cache[master_path]["theme_path"] else {"colors": {}, "fonts": {}},
)
for seq, master_path in enumerate(master_parts, start=1)
if master_path in master_cache
]
layouts_top = [item for item in layout_records if item]
masters_top = [item for item in master_records if item]
layout_by_path = {item["path"]: item for item in layouts_top}
master_by_path = {item["path"]: item for item in masters_top}
asset_usage = Counter()
for slide in slide_records:
per_slide_assets: set[str] = set(slide.image_assets)
if slide.background_asset:
per_slide_assets.add(slide.background_asset)
layout_record = layout_by_path.get(slide.layout_path or "")
master_record = master_by_path.get(slide.master_path or "")
per_slide_assets.update(_effective_inherited_image_assets(
show_inherited_shapes=slide.show_inherited_shapes,
layout_record=layout_record,
master_record=master_record,
))
for asset in per_slide_assets:
if asset:
asset_usage[asset] += 1
common_assets = choose_common_assets(asset_usage)
if not theme_summary["colors"] and not theme_summary["fonts"] and masters_top:
theme_summary = masters_top[0].get("theme") or {"colors": {}, "fonts": {}}
manifest = {
"source": {
"pptx": str(pptx_path),
"name": pptx_path.name,
},
"slideSize": slide_size,
"theme": theme_summary,
"assets": {
"exportDir": "assets",
"commonAssets": common_assets,
"allAssets": sorted(copied_assets.values()),
"assetMap": copied_assets,
},
"pageTypeCandidates": dict(sorted(page_type_map.items())),
"layouts": layouts_top,
"masters": masters_top,
"slides": [
{
"index": slide.index,
"name": slide.name,
"svgFile": slide.svg_file,
"flatSvgFile": slide.flat_svg_file,
"slidePath": slide.slide_path,
"layoutPath": slide.layout_path,
"masterPath": slide.master_path,
"showInheritedShapes": slide.show_inherited_shapes,
"backgroundAsset": slide.background_asset,
"backgroundSource": slide.background_source,
"imageAssets": slide.image_assets,
"textSamples": slide.text_samples,
"textCount": slide.text_count,
"shapeCount": slide.shape_count,
"placeholders": slide.placeholders,
"pageType": slide.page_type,
}
for slide in slide_records
],
}
return manifest