824 lines
30 KiB
Python
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
|