Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user