返回 ppt-master
beautify_identity.py
根目录 / skills / ppt-master / scripts / beautify_identity.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Beautify Identity Extractor
4
5 Extract a source deck's visual identity as JSON for the beautify-pptx profile:
6 the declared `theme` (palette + major/minor fonts + master placeholder sizes,
7 full bodyStyle ramp in `sizes.body_levels`) plus `observed` usage (run-level
8 fonts incl. CJK `ea`, explicit point sizes, and frequent explicit fill colors)
9 sampled across slides, plus `layout_sizes_pt` (in-use layout body-placeholder
10 level-1 sizes — a reference hint, not an auto-seed) — so the workflow can
11 recommend theme vs actual-usage identity, incl. a source-derived body size
12 (seed chain: observed → theme.sizes.body → canvas baseline) and let the user
13 confirm. Pure read: reuses the pptx_to_svg resolver, writes
14 no PPTX.
15
16 Usage:
17 python3 scripts/beautify_identity.py <source.pptx> [-o identity.json]
18
19 Examples:
20 python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
21 python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/deck.identity.json
22
23 Dependencies:
24 None beyond the standard library (reuses scripts/pptx_to_svg/).
25
26 See workflows/profiles/beautify-pptx.md for how the emitted identity is consumed.
27 """
28
29 from __future__ import annotations
30
31 import argparse
32 import json
33 import sys
34 from pathlib import Path
35 from typing import Optional
36
37 _SCRIPTS_DIR = Path(__file__).resolve().parent
38 if str(_SCRIPTS_DIR) not in sys.path:
39 sys.path.insert(0, str(_SCRIPTS_DIR))
40
41 from console_encoding import configure_utf8_stdio # noqa: E402
42 from pptx_to_svg.color_resolver import ColorPalette # noqa: E402
43 from pptx_to_svg.emu_units import NS # noqa: E402
44 from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
45
46 configure_utf8_stdio()
47
48
49 def _font_pair(theme_root, font_tag: str) -> dict[str, object]:
50 """Read one theme font family, including explicit CJK script mappings."""
51 out: dict[str, object] = {}
52 font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
53 if font is None:
54 return out
55 for slot, key in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
56 elem = font.find(slot, NS)
57 if elem is not None:
58 face = (elem.attrib.get("typeface") or "").strip()
59 if face:
60 out[key] = face
61 scripts: dict[str, str] = {}
62 for elem in font.findall("a:font", NS):
63 script = (elem.attrib.get("script") or "").strip()
64 face = (elem.attrib.get("typeface") or "").strip()
65 if script in {"Hans", "Hant", "Jpan", "Hang"} and face:
66 scripts[script] = face
67 if scripts:
68 out["scripts"] = scripts
69 return out
70
71
72 def _rank(counter: dict, limit: int) -> list[dict]:
73 """Frequency-ranked [{value, count}, ...], most common first."""
74 ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
75 return [{"value": v, "count": n} for v, n in ranked[:limit]]
76
77
78 def _master_text_sizes(master_root) -> dict:
79 """Declared title/body point sizes from the master's <p:txStyles>.
80
81 `title` is <p:titleStyle> level-1; `body` is <p:bodyStyle> level-1 and
82 `body_levels` is every declared body outline level in order (lvl1..lvl9).
83 `sz` is in hundredths of a point. This is the *declared* size — the
84 placeholder default a run inherits when it sets no explicit `sz` — the size
85 counterpart of `theme.fonts`. Note level-1 is the coarsest/largest body
86 level and commonly over-reads real density; `body_levels` exposes the full
87 ramp. Returns {} when no txStyles are present.
88 """
89 out: dict[str, object] = {}
90 title = master_root.find(".//p:txStyles/p:titleStyle//a:defRPr", NS)
91 if title is not None:
92 sz = (title.attrib.get("sz") or "").strip()
93 if sz.isdigit():
94 out["title"] = int(sz) / 100
95 body = master_root.find(".//p:txStyles/p:bodyStyle", NS)
96 levels: list[float] = []
97 if body is not None:
98 for lvl in body: # a:lvl1pPr .. a:lvl9pPr, in document order
99 defrpr = lvl.find("a:defRPr", NS)
100 if defrpr is None:
101 continue
102 sz = (defrpr.attrib.get("sz") or "").strip()
103 if sz.isdigit():
104 levels.append(int(sz) / 100)
105 if levels:
106 out["body"] = levels[0]
107 out["body_levels"] = levels
108 return out
109
110
111 # Placeholder types that carry chrome, not body text — excluded from the layout
112 # body-size sample so a 9pt footer / slide-number doesn't masquerade as body.
113 _NON_BODY_PH = {"title", "ctrTitle", "ftr", "sldNum", "dt", "hdr"}
114
115
116 def _layout_text_sizes(pkg: "OoxmlPackage") -> list[dict]:
117 """Frequency-ranked **body** placeholder `defRPr` sizes across the layouts in use.
118
119 For a theme/template-driven deck whose runs set no explicit `sz` (everything
120 inherits its placeholder), this is where a real body size can show: each
121 layout's body placeholder may declare its own size where the master only gives
122 the coarse level-1 default. Only layouts actually referenced by a slide count,
123 only body-ish placeholders (title / footer / slide-number / date / header
124 skipped), and only the placeholder's **level-1** `defRPr` — the primary size —
125 so deeper outline levels (lvl2..lvl9) don't masquerade as body. Sizes in
126 points. Often sparse: placeholders that don't override level-1 inherit the
127 master default and contribute nothing here.
128 """
129 sizes: dict[float, int] = {}
130 seen: set[str] = set()
131 for slide in pkg.iter_slides():
132 layout = slide.layout
133 if layout is None or layout.path in seen:
134 continue
135 seen.add(layout.path)
136 for sp in layout.xml.iterfind(".//p:sp", NS):
137 ph = sp.find(".//p:nvSpPr/p:nvPr/p:ph", NS)
138 ph_type = ph.attrib.get("type") if ph is not None else None
139 if ph_type in _NON_BODY_PH:
140 continue
141 lvl1 = sp.find(".//a:lstStyle/a:lvl1pPr/a:defRPr", NS)
142 if lvl1 is None:
143 continue
144 sz = (lvl1.attrib.get("sz") or "").strip()
145 if sz.isdigit():
146 pt = int(sz) / 100
147 sizes[pt] = sizes.get(pt, 0) + 1
148 return _rank(sizes, 10)
149
150
151 def _sample_observed(pkg: "OoxmlPackage") -> dict:
152 """Aggregate run-level fonts, explicit point sizes, and explicit fill colors.
153
154 Theme extraction reports the *declared* identity; a hand-edited deck often
155 overrides it per shape / run. This is a frequency sample of run-level usage
156 (not a full style resolution — it misses schemeClr + master/layout
157 inheritance, and counts chart/gradient fills), enough for the workflow to
158 recommend theme vs observed. `sizes_pt` only counts runs that set an explicit
159 `sz`; runs inheriting the placeholder size are not seen here (use
160 `theme.sizes` for those), so a small sample is a hint, not the full picture.
161 """
162 latin: dict[str, int] = {}
163 ea: dict[str, int] = {}
164 sizes: dict[float, int] = {}
165 colors: dict[str, int] = {}
166 for slide in pkg.iter_slides():
167 root = slide.part.xml
168 for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
169 for elem in root.iterfind(f".//{tag}", NS):
170 face = (elem.attrib.get("typeface") or "").strip()
171 if face and not face.startswith("+"): # skip +mj-*/+mn-* theme refs
172 bucket[face] = bucket.get(face, 0) + 1
173 for elem in root.iterfind(".//a:rPr", NS):
174 sz = (elem.attrib.get("sz") or "").strip()
175 if sz.isdigit():
176 pt = int(sz) / 100
177 sizes[pt] = sizes.get(pt, 0) + 1
178 for elem in root.iterfind(".//a:srgbClr", NS):
179 val = (elem.attrib.get("val") or "").strip().upper()
180 if val:
181 colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
182 return {
183 "fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
184 "sizes_pt": _rank(sizes, 8),
185 "colors": _rank(colors, 8),
186 }
187
188
189 def extract_identity(pptx_path: Path) -> dict:
190 """Resolve the deck's theme + observed-usage identity, plus canvas."""
191 with OoxmlPackage(pptx_path) as pkg:
192 first = pkg.get_slide(1)
193 master = first.master if first else None
194 theme = pkg.resolve_theme(master)
195 palette_resolver = ColorPalette(master, theme, strict=False)
196
197 # Presentation-level scheme names; ColorPalette applies clrMap + aliases.
198 scheme = {
199 "background": palette_resolver.resolve_scheme("bg1"),
200 "background_alt": palette_resolver.resolve_scheme("bg2"),
201 "text": palette_resolver.resolve_scheme("tx1"),
202 "text_alt": palette_resolver.resolve_scheme("tx2"),
203 "hyperlink": palette_resolver.resolve_scheme("hlink"),
204 }
205 accents = {
206 f"accent{i}": palette_resolver.resolve_scheme(f"accent{i}")
207 for i in range(1, 7)
208 }
209 palette = {
210 k: (f"#{v}" if v else None)
211 for k, v in {**scheme, **accents}.items()
212 }
213 # accent1 is the conventional primary.
214 palette["primary"] = palette.get("accent1")
215
216 fonts = {}
217 if theme is not None:
218 fonts = {
219 "title": _font_pair(theme.xml, "majorFont"),
220 "body": _font_pair(theme.xml, "minorFont"),
221 }
222 sizes = _master_text_sizes(master.xml) if master is not None else {}
223
224 w, h = pkg.slide_size_px
225 canvas = {
226 "width_px": round(w),
227 "height_px": round(h),
228 "aspect": round(w / h, 4) if h else None,
229 }
230
231 return {
232 "source": str(pptx_path),
233 "slide_count": pkg.slide_count,
234 "canvas": canvas,
235 "theme": {"palette": palette, "fonts": fonts, "sizes": sizes},
236 "observed": _sample_observed(pkg),
237 "layout_sizes_pt": _layout_text_sizes(pkg),
238 }
239
240
241 def build_parser() -> argparse.ArgumentParser:
242 parser = argparse.ArgumentParser(
243 description="Extract a source deck's theme palette + fonts + sizes + canvas as JSON.",
244 formatter_class=argparse.RawDescriptionHelpFormatter,
245 )
246 parser.add_argument("source", help="Source .pptx file")
247 parser.add_argument(
248 "-o", "--output",
249 help="Write JSON here (default: stdout)",
250 )
251 return parser
252
253
254 def main(argv: Optional[list[str]] = None) -> int:
255 args = build_parser().parse_args(argv)
256 src = Path(args.source)
257 if not src.is_file():
258 print(f"[ERROR] source not found: {src}", file=sys.stderr)
259 return 1
260
261 try:
262 identity = extract_identity(src)
263 except (RuntimeError, KeyError, ValueError) as exc:
264 print(f"[ERROR] failed to extract identity: {exc}", file=sys.stderr)
265 return 1
266
267 payload = json.dumps(identity, ensure_ascii=False, indent=2)
268 if args.output:
269 out = Path(args.output)
270 out.parent.mkdir(parents=True, exist_ok=True)
271 out.write_text(payload + "\n", encoding="utf-8")
272 print(f"[OK] identity written to: {out}", file=sys.stderr)
273 else:
274 print(payload)
275 return 0
276
277
278 if __name__ == "__main__":
279 raise SystemExit(main())
280
280 lines PYTHON