Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""
PPT Master - Beautify Identity Extractor
Extract a source deck's visual identity as JSON for the beautify-pptx profile:
the declared `theme` (palette + major/minor fonts + master placeholder sizes,
full bodyStyle ramp in `sizes.body_levels`) plus `observed` usage (run-level
fonts incl. CJK `ea`, explicit point sizes, and frequent explicit fill colors)
sampled across slides, plus `layout_sizes_pt` (in-use layout body-placeholder
level-1 sizes — a reference hint, not an auto-seed) — so the workflow can
recommend theme vs actual-usage identity, incl. a source-derived body size
(seed chain: observed → theme.sizes.body → canvas baseline) and let the user
confirm. Pure read: reuses the pptx_to_svg resolver, writes
no PPTX.
Usage:
python3 scripts/beautify_identity.py <source.pptx> [-o identity.json]
Examples:
python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/deck.identity.json
Dependencies:
None beyond the standard library (reuses scripts/pptx_to_svg/).
See workflows/profiles/beautify-pptx.md for how the emitted identity is consumed.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Optional
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_to_svg.color_resolver import ColorPalette # noqa: E402
from pptx_to_svg.emu_units import NS # noqa: E402
from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
configure_utf8_stdio()
def _font_pair(theme_root, font_tag: str) -> dict[str, object]:
"""Read one theme font family, including explicit CJK script mappings."""
out: dict[str, object] = {}
font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
if font is None:
return out
for slot, key in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
elem = font.find(slot, NS)
if elem is not None:
face = (elem.attrib.get("typeface") or "").strip()
if face:
out[key] = face
scripts: dict[str, str] = {}
for elem in font.findall("a:font", NS):
script = (elem.attrib.get("script") or "").strip()
face = (elem.attrib.get("typeface") or "").strip()
if script in {"Hans", "Hant", "Jpan", "Hang"} and face:
scripts[script] = face
if scripts:
out["scripts"] = scripts
return out
def _rank(counter: dict, limit: int) -> list[dict]:
"""Frequency-ranked [{value, count}, ...], most common first."""
ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
return [{"value": v, "count": n} for v, n in ranked[:limit]]
def _master_text_sizes(master_root) -> dict:
"""Declared title/body point sizes from the master's <p:txStyles>.
`title` is <p:titleStyle> level-1; `body` is <p:bodyStyle> level-1 and
`body_levels` is every declared body outline level in order (lvl1..lvl9).
`sz` is in hundredths of a point. This is the *declared* size — the
placeholder default a run inherits when it sets no explicit `sz` — the size
counterpart of `theme.fonts`. Note level-1 is the coarsest/largest body
level and commonly over-reads real density; `body_levels` exposes the full
ramp. Returns {} when no txStyles are present.
"""
out: dict[str, object] = {}
title = master_root.find(".//p:txStyles/p:titleStyle//a:defRPr", NS)
if title is not None:
sz = (title.attrib.get("sz") or "").strip()
if sz.isdigit():
out["title"] = int(sz) / 100
body = master_root.find(".//p:txStyles/p:bodyStyle", NS)
levels: list[float] = []
if body is not None:
for lvl in body: # a:lvl1pPr .. a:lvl9pPr, in document order
defrpr = lvl.find("a:defRPr", NS)
if defrpr is None:
continue
sz = (defrpr.attrib.get("sz") or "").strip()
if sz.isdigit():
levels.append(int(sz) / 100)
if levels:
out["body"] = levels[0]
out["body_levels"] = levels
return out
# Placeholder types that carry chrome, not body text — excluded from the layout
# body-size sample so a 9pt footer / slide-number doesn't masquerade as body.
_NON_BODY_PH = {"title", "ctrTitle", "ftr", "sldNum", "dt", "hdr"}
def _layout_text_sizes(pkg: "OoxmlPackage") -> list[dict]:
"""Frequency-ranked **body** placeholder `defRPr` sizes across the layouts in use.
For a theme/template-driven deck whose runs set no explicit `sz` (everything
inherits its placeholder), this is where a real body size can show: each
layout's body placeholder may declare its own size where the master only gives
the coarse level-1 default. Only layouts actually referenced by a slide count,
only body-ish placeholders (title / footer / slide-number / date / header
skipped), and only the placeholder's **level-1** `defRPr` — the primary size —
so deeper outline levels (lvl2..lvl9) don't masquerade as body. Sizes in
points. Often sparse: placeholders that don't override level-1 inherit the
master default and contribute nothing here.
"""
sizes: dict[float, int] = {}
seen: set[str] = set()
for slide in pkg.iter_slides():
layout = slide.layout
if layout is None or layout.path in seen:
continue
seen.add(layout.path)
for sp in layout.xml.iterfind(".//p:sp", NS):
ph = sp.find(".//p:nvSpPr/p:nvPr/p:ph", NS)
ph_type = ph.attrib.get("type") if ph is not None else None
if ph_type in _NON_BODY_PH:
continue
lvl1 = sp.find(".//a:lstStyle/a:lvl1pPr/a:defRPr", NS)
if lvl1 is None:
continue
sz = (lvl1.attrib.get("sz") or "").strip()
if sz.isdigit():
pt = int(sz) / 100
sizes[pt] = sizes.get(pt, 0) + 1
return _rank(sizes, 10)
def _sample_observed(pkg: "OoxmlPackage") -> dict:
"""Aggregate run-level fonts, explicit point sizes, and explicit fill colors.
Theme extraction reports the *declared* identity; a hand-edited deck often
overrides it per shape / run. This is a frequency sample of run-level usage
(not a full style resolution — it misses schemeClr + master/layout
inheritance, and counts chart/gradient fills), enough for the workflow to
recommend theme vs observed. `sizes_pt` only counts runs that set an explicit
`sz`; runs inheriting the placeholder size are not seen here (use
`theme.sizes` for those), so a small sample is a hint, not the full picture.
"""
latin: dict[str, int] = {}
ea: dict[str, int] = {}
sizes: dict[float, int] = {}
colors: dict[str, int] = {}
for slide in pkg.iter_slides():
root = slide.part.xml
for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
for elem in root.iterfind(f".//{tag}", NS):
face = (elem.attrib.get("typeface") or "").strip()
if face and not face.startswith("+"): # skip +mj-*/+mn-* theme refs
bucket[face] = bucket.get(face, 0) + 1
for elem in root.iterfind(".//a:rPr", NS):
sz = (elem.attrib.get("sz") or "").strip()
if sz.isdigit():
pt = int(sz) / 100
sizes[pt] = sizes.get(pt, 0) + 1
for elem in root.iterfind(".//a:srgbClr", NS):
val = (elem.attrib.get("val") or "").strip().upper()
if val:
colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
return {
"fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
"sizes_pt": _rank(sizes, 8),
"colors": _rank(colors, 8),
}
def extract_identity(pptx_path: Path) -> dict:
"""Resolve the deck's theme + observed-usage identity, plus canvas."""
with OoxmlPackage(pptx_path) as pkg:
first = pkg.get_slide(1)
master = first.master if first else None
theme = pkg.resolve_theme(master)
palette_resolver = ColorPalette(master, theme, strict=False)
# Presentation-level scheme names; ColorPalette applies clrMap + aliases.
scheme = {
"background": palette_resolver.resolve_scheme("bg1"),
"background_alt": palette_resolver.resolve_scheme("bg2"),
"text": palette_resolver.resolve_scheme("tx1"),
"text_alt": palette_resolver.resolve_scheme("tx2"),
"hyperlink": palette_resolver.resolve_scheme("hlink"),
}
accents = {
f"accent{i}": palette_resolver.resolve_scheme(f"accent{i}")
for i in range(1, 7)
}
palette = {
k: (f"#{v}" if v else None)
for k, v in {**scheme, **accents}.items()
}
# accent1 is the conventional primary.
palette["primary"] = palette.get("accent1")
fonts = {}
if theme is not None:
fonts = {
"title": _font_pair(theme.xml, "majorFont"),
"body": _font_pair(theme.xml, "minorFont"),
}
sizes = _master_text_sizes(master.xml) if master is not None else {}
w, h = pkg.slide_size_px
canvas = {
"width_px": round(w),
"height_px": round(h),
"aspect": round(w / h, 4) if h else None,
}
return {
"source": str(pptx_path),
"slide_count": pkg.slide_count,
"canvas": canvas,
"theme": {"palette": palette, "fonts": fonts, "sizes": sizes},
"observed": _sample_observed(pkg),
"layout_sizes_pt": _layout_text_sizes(pkg),
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Extract a source deck's theme palette + fonts + sizes + canvas as JSON.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("source", help="Source .pptx file")
parser.add_argument(
"-o", "--output",
help="Write JSON here (default: stdout)",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
src = Path(args.source)
if not src.is_file():
print(f"[ERROR] source not found: {src}", file=sys.stderr)
return 1
try:
identity = extract_identity(src)
except (RuntimeError, KeyError, ValueError) as exc:
print(f"[ERROR] failed to extract identity: {exc}", file=sys.stderr)
return 1
payload = json.dumps(identity, ensure_ascii=False, indent=2)
if args.output:
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(payload + "\n", encoding="utf-8")
print(f"[OK] identity written to: {out}", file=sys.stderr)
else:
print(payload)
return 0
if __name__ == "__main__":
raise SystemExit(main())