@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2288 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
package/build_deck.py ADDED
@@ -0,0 +1,2288 @@
1
+ #!/usr/bin/env python3
2
+ """Build an editable deck from JSON and explicit assets, independent of cwd.
3
+
4
+ Relative image paths resolve beside the specification. Layout and rendering
5
+ code live in this package; workspace scripts are never imported or executed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import math
14
+ from pathlib import Path
15
+
16
+ from PIL import Image, ImageDraw, ImageOps
17
+ from pptx import Presentation
18
+ from pptx.chart.data import CategoryChartData
19
+ from pptx.dml.color import RGBColor
20
+ from pptx.enum.chart import XL_CHART_TYPE
21
+ from pptx.enum.shapes import MSO_SHAPE
22
+ from pptx.enum.shapes import MSO_CONNECTOR
23
+ from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
24
+ from pptx.oxml.ns import qn
25
+ from pptx.util import Inches, Pt
26
+ from pptx.opc.constants import RELATIONSHIP_TYPE as RT
27
+
28
+ import media
29
+ from charts import draw_chart, resolve_chart
30
+
31
+ from grasp_evidence import (candidate_projections, depth_colors, depth_pair,
32
+ gated_candidate_projections, gripper_projection)
33
+
34
+ LAYOUTS = {"cover", "cards", "process", "comparison", "table", "chart",
35
+ "image_split", "image_grid", "image_triptych", "ingredient_board", "ingredient_split", "state_triptych", "state_band", "state_gallery", "prep_stove", "heat_bars", "chart_dashboard", "closing",
36
+ "image_hero", "image_overlay", "image_wall",
37
+ "section_number", "image_statement",
38
+ "split_diagonal", "glass_overlay", "quote_stage", "kpi_showcase",
39
+ "cutout_statement", "image_annotated", "image_arch"}
40
+ THEMES = {
41
+ "midnight": {"bg": "0B1324", "panel": "17243B", "text": "F7FBFF",
42
+ "muted": "B2C2D9", "accent": "48CAE4"},
43
+ "paper": {"bg": "F4F6FA", "panel": "FFFFFF", "text": "15253F",
44
+ "muted": "4D627A", "accent": "176C88", "danger": "C0392B"},
45
+ }
46
+
47
+
48
+ def validate(spec: dict, base: Path) -> None:
49
+ """Reject unsupported layouts, missing assets and content beyond capacity."""
50
+ if not isinstance(spec, dict) or not isinstance(spec.get("meta"), dict):
51
+ raise ValueError("meta must be an object")
52
+ if not spec["meta"].get("title"):
53
+ raise ValueError("meta.title is required")
54
+ theme = spec["meta"].get("theme", "midnight")
55
+ if theme not in THEMES:
56
+ raise ValueError(f"meta.theme must be one of {sorted(THEMES)}")
57
+ overrides = spec["meta"].get("colors") or {}
58
+ if not isinstance(overrides, dict):
59
+ raise ValueError("meta.colors must be an object")
60
+ for key, value in overrides.items():
61
+ if key not in THEMES[theme]:
62
+ raise ValueError(f"meta.colors.{key} is not a {theme} token")
63
+ text = str(value).strip().lstrip("#")
64
+ if len(text) != 6 or any(char not in "0123456789ABCDEFabcdef" for char in text):
65
+ raise ValueError(f"meta.colors.{key} must be a 6-digit hex color")
66
+ title_font = spec["meta"].get("titleFont")
67
+ if title_font is not None and (not isinstance(title_font, str) or not title_font.strip()):
68
+ raise ValueError("meta.titleFont must be a non-empty string")
69
+ effect = spec["meta"].get("transition")
70
+ if effect not in {None, "fade", "push", "morph"}:
71
+ raise ValueError("meta.transition must be fade, push or morph")
72
+ accent = spec["meta"].get("accent")
73
+ if accent is not None:
74
+ text = str(accent).strip().lstrip("#")
75
+ if len(text) != 6 or any(char not in "0123456789ABCDEFabcdef" for char in text):
76
+ raise ValueError("meta.accent must be a 6-digit hex color")
77
+ slides = spec.get("slides")
78
+ seen_ids: set[str] = set()
79
+ if not isinstance(slides, list) or not slides:
80
+ raise ValueError("slides must be a non-empty array")
81
+ for index, slide in enumerate(slides, 1):
82
+ if not isinstance(slide, dict) or slide.get("layout") not in LAYOUTS:
83
+ raise ValueError(f"slide {index}: unsupported layout")
84
+ stable_id = slide.get("id", f"P{index:02d}")
85
+ if (not isinstance(stable_id, str) or not 1 <= len(stable_id) <= 24
86
+ or any(char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
87
+ for char in stable_id)):
88
+ raise ValueError(f"slide {index}: id must be 1-24 word characters")
89
+ if stable_id in seen_ids:
90
+ raise ValueError(f"slide {index}: duplicate id {stable_id}")
91
+ seen_ids.add(stable_id)
92
+ layout = slide["layout"]
93
+ if not slide.get("title") and layout not in {"cover", "closing"}:
94
+ raise ValueError(f"slide {index}: title is required")
95
+ if layout in {"cards", "process", "comparison", "image_grid"}:
96
+ items = slide.get("items")
97
+ if layout == "comparison":
98
+ minimum, maximum = 2, 2
99
+ elif layout == "process":
100
+ minimum, maximum = 2, 5
101
+ else:
102
+ minimum, maximum = 2, 4
103
+ if not isinstance(items, list) or not minimum <= len(items) <= maximum:
104
+ raise ValueError(f"slide {index}: items requires {minimum}–{maximum} entries")
105
+ if any(not isinstance(item, dict) or not item.get("title") for item in items):
106
+ raise ValueError(f"slide {index}: each item requires a title")
107
+ assets = [slide] if layout in {"cover", "image_split", "image_hero",
108
+ "image_overlay", "image_statement",
109
+ "glass_overlay", "split_diagonal"} else []
110
+ if layout == "heat_bars":
111
+ items = slide.get("items")
112
+ if not isinstance(items, list) or not 2 <= len(items) <= 5:
113
+ raise ValueError(f"slide {index}: heat_bars requires 2–5 items")
114
+ for item in items:
115
+ if not isinstance(item, dict) or not item.get("title") or not item.get("body"):
116
+ raise ValueError(f"slide {index}: every heat_bars item requires title and body")
117
+ if item.get("fire") not in {"大火", "中火", "小火", "微火"}:
118
+ raise ValueError(f"slide {index}: heat_bars fire must be 大火/中火/小火/微火")
119
+ if not isinstance(item.get("minutes"), (int, float)) or float(item["minutes"]) <= 0:
120
+ raise ValueError(f"slide {index}: heat_bars minutes must be positive")
121
+ if layout == "ingredient_split":
122
+ if not slide.get("image"):
123
+ raise ValueError(f"slide {index}: ingredient_split requires an image")
124
+ items = slide.get("items")
125
+ if not isinstance(items, list) or not 3 <= len(items) <= 6:
126
+ raise ValueError(f"slide {index}: ingredient_split requires 3–6 items")
127
+ for item in items:
128
+ if not isinstance(item, dict) or not item.get("label") or not item.get("value"):
129
+ raise ValueError(f"slide {index}: every ingredient item requires label and value")
130
+ assets = [slide]
131
+ if layout == "state_gallery":
132
+ items = slide.get("items")
133
+ if not isinstance(items, list) or len(items) != 3:
134
+ raise ValueError(f"slide {index}: state_gallery requires exactly 3 items")
135
+ if any(not isinstance(item, dict) or not item.get("image") for item in items):
136
+ raise ValueError(f"slide {index}: every state item requires an image")
137
+ assets = items
138
+ if layout == "state_band":
139
+ items = slide.get("items")
140
+ if not isinstance(items, list) or len(items) != 3:
141
+ raise ValueError(f"slide {index}: state_band requires exactly 3 items")
142
+ if any(not isinstance(item, dict) or not item.get("image") for item in items):
143
+ raise ValueError(f"slide {index}: every state item requires an image")
144
+ assets = items
145
+ if layout == "state_triptych":
146
+ items = slide.get("items")
147
+ if not isinstance(items, list) or len(items) != 3:
148
+ raise ValueError(f"slide {index}: state_triptych requires exactly 3 items")
149
+ if any(not isinstance(item, dict) or not item.get("image") for item in items):
150
+ raise ValueError(f"slide {index}: every state item requires an image")
151
+ assets = items
152
+ if layout == "ingredient_board":
153
+ if not slide.get("image"):
154
+ raise ValueError(f"slide {index}: ingredient_board requires an image")
155
+ items = slide.get("items")
156
+ if not isinstance(items, list) or not 3 <= len(items) <= 6:
157
+ raise ValueError(f"slide {index}: ingredient_board requires 3–6 items")
158
+ for item in items:
159
+ if not isinstance(item, dict) or not item.get("label") or not item.get("value"):
160
+ raise ValueError(f"slide {index}: every ingredient item requires label and value")
161
+ assets = [slide]
162
+ if layout == "prep_stove":
163
+ items = slide.get("items")
164
+ if not isinstance(items, list) or not 3 <= len(items) <= 6:
165
+ raise ValueError(f"slide {index}: prep_stove requires 3–6 items")
166
+ for item in items:
167
+ if not isinstance(item, dict) or item.get("lane") not in {"prep", "stove"}:
168
+ raise ValueError(f"slide {index}: prep_stove lane must be prep or stove")
169
+ if not isinstance(item.get("minutes"), (int, float)) or float(item["minutes"]) <= 0:
170
+ raise ValueError(f"slide {index}: prep_stove minutes must be positive")
171
+ if not item.get("title") or not item.get("body"):
172
+ raise ValueError(f"slide {index}: prep_stove item requires title and body")
173
+ if layout == "image_triptych":
174
+ items = slide.get("items")
175
+ if not isinstance(items, list) or len(items) != 3:
176
+ raise ValueError(f"slide {index}: image_triptych requires exactly 3 items")
177
+ if any(not isinstance(item, dict) or not item.get("image") for item in items):
178
+ raise ValueError(f"slide {index}: every triptych item requires an image")
179
+ assets = items
180
+ if layout == "image_grid":
181
+ assets = slide["items"]
182
+ if layout == "section_number" and slide.get("image"):
183
+ assets = [slide]
184
+ if slide.get("art") != "image":
185
+ raise ValueError(f"slide {index}: section_number art must be image when image is set")
186
+ if layout in {"section_number", "image_statement"}:
187
+ if slide.get("art", "gradient") not in {"gradient", "outline", "image"}:
188
+ raise ValueError(f"slide {index}: art must be gradient, outline or image")
189
+ if layout in {"cutout_statement", "image_arch"}:
190
+ if not slide.get("image"):
191
+ raise ValueError(f"slide {index}: {layout} requires an image")
192
+ assets = [slide]
193
+ if layout == "image_annotated":
194
+ points = slide.get("points")
195
+ if not isinstance(points, list) or not 2 <= len(points) <= 4:
196
+ raise ValueError(f"slide {index}: image_annotated requires 2–4 points")
197
+ for point in points:
198
+ if not isinstance(point, dict) or not point.get("text"):
199
+ raise ValueError(f"slide {index}: every point requires text")
200
+ if not 0 <= float(point.get("x", -1)) <= 1 or not 0 <= float(point.get("y", -1)) <= 1:
201
+ raise ValueError(f"slide {index}: point x/y must be 0–1")
202
+ assets = [slide]
203
+ if layout == "split_diagonal":
204
+ images = slide.get("images")
205
+ if not isinstance(images, list) or len(images) != 2:
206
+ raise ValueError(f"slide {index}: split_diagonal requires images: [a, b]")
207
+ assets = [{"image": item} for item in images]
208
+ if layout == "quote_stage" and not str(slide.get("quote", "")).strip():
209
+ raise ValueError(f"slide {index}: quote_stage requires quote text")
210
+ if layout == "kpi_showcase":
211
+ stats = slide.get("stats")
212
+ if not isinstance(stats, list) or not 2 <= len(stats) <= 3:
213
+ raise ValueError(f"slide {index}: kpi_showcase requires 2–3 stats")
214
+ if any(not isinstance(item, dict) or not item.get("value") for item in stats):
215
+ raise ValueError(f"slide {index}: every stat requires value")
216
+ if layout == "image_wall":
217
+ items = slide.get("items")
218
+ if not isinstance(items, list) or not 2 <= len(items) <= 4:
219
+ raise ValueError(f"slide {index}: image_wall requires 2–4 items")
220
+ if any(not isinstance(item, dict) or not item.get("image")
221
+ or not item.get("title") for item in items):
222
+ raise ValueError(f"slide {index}: every wall item requires image and title")
223
+ assets = items
224
+ if layout in {"image_hero", "image_overlay"}:
225
+ if not slide.get("image"):
226
+ raise ValueError(f"slide {index}: {layout} requires an image")
227
+ if slide.get("ink", "light") not in {"light", "dark"}:
228
+ raise ValueError(f"slide {index}: ink must be light or dark")
229
+ allowed = {"left", "right", "bottom", "top", "diagonal"}
230
+ if layout == "image_hero" and slide.get("scrim", "left") not in allowed:
231
+ raise ValueError(f"slide {index}: scrim must be one of {sorted(allowed)}")
232
+ if layout == "image_overlay" and slide.get("side", "left") not in {"left", "right"}:
233
+ raise ValueError(f"slide {index}: image_overlay side must be left or right")
234
+ for asset in assets:
235
+ image = asset.get("image")
236
+ if not image and layout != "cover":
237
+ raise ValueError(f"slide {index}: image is required")
238
+ if image:
239
+ path = (base / image).resolve()
240
+ if not path.is_file():
241
+ raise ValueError(f"slide {index}: image not found: {path}")
242
+ with Image.open(path) as picture:
243
+ picture.verify()
244
+ if layout == "table":
245
+ headers, rows = slide.get("headers"), slide.get("rows")
246
+ if (not isinstance(headers, list) or not 2 <= len(headers) <= 5
247
+ or not isinstance(rows, list) or not 1 <= len(rows) <= 6
248
+ or any(not isinstance(row, list) or len(row) != len(headers) for row in rows)):
249
+ raise ValueError(f"slide {index}: table requires 2–5 columns and 1–6 complete rows")
250
+ if layout in {"chart", "chart_dashboard"}:
251
+ validate_chart_page(slide, base)
252
+
253
+
254
+ def validate_chart_page(page: dict, base: Path) -> None:
255
+ """Validate chart payloads before the output can be replaced."""
256
+ if page["layout"] == "chart":
257
+ charts = [page.get("chart", {})]
258
+ else:
259
+ charts = page.get("charts")
260
+ if not isinstance(charts, list) or not 2 <= len(charts) <= 4:
261
+ raise ValueError("chart_dashboard requires 2–4 chart objects")
262
+ for chart in charts:
263
+ resolve_chart(chart, base)
264
+ for field in ("title", "unit", "source", "takeaway"):
265
+ if field in chart and not isinstance(chart[field], str):
266
+ raise ValueError(f"chart.{field} must be a string")
267
+ if "takeaway" in page and not isinstance(page["takeaway"], str):
268
+ raise ValueError("takeaway must be a string")
269
+
270
+
271
+ def _soft_shadow(shape, blur_pt=14, distance_pt=6, opacity=34000,
272
+ color="000000") -> None:
273
+ """Attach a restrained outer shadow to a picture (DrawingML, editable)."""
274
+ sp_pr = shape._element.spPr
275
+ for tag in ("a:effectLst", "a:effectDag"):
276
+ for old in sp_pr.findall(qn(tag)):
277
+ sp_pr.remove(old)
278
+ effect = sp_pr.makeelement(qn("a:effectLst"), {})
279
+ shadow = sp_pr.makeelement(qn("a:outerShdw"), {
280
+ "blurRad": str(int(blur_pt * 12700)),
281
+ "dist": str(int(distance_pt * 12700)),
282
+ "dir": "5400000", "algn": "ctr", "rotWithShape": "0"})
283
+ shade = sp_pr.makeelement(qn("a:srgbClr"), {"val": color})
284
+ shade.append(sp_pr.makeelement(qn("a:alpha"), {"val": str(opacity)}))
285
+ shadow.append(shade)
286
+ effect.append(shadow)
287
+ sp_pr.append(effect)
288
+
289
+
290
+ class DeckBuilder:
291
+ """Own presentation state and layout primitives for one specification."""
292
+
293
+ def __init__(self, spec: dict, base: Path):
294
+ self.meta = spec["meta"]
295
+ self.base = base
296
+ theme = THEMES[self.meta.get("theme", "midnight")]
297
+ self.colors = dict(theme)
298
+ for key, value in (self.meta.get("colors") or {}).items():
299
+ self.colors[key] = str(value).strip().lstrip("#").upper()
300
+ if self.meta.get("accent"):
301
+ self.colors["accent"] = str(self.meta["accent"]).strip().lstrip("#").upper()
302
+ self.font = self.meta.get("font", "Noto Sans CJK SC")
303
+ self.head_font = self.meta.get("titleFont") or self.font
304
+ self.prs = Presentation()
305
+ self.prs.slide_width, self.prs.slide_height = Inches(13.333333), Inches(7.5)
306
+
307
+ def box(self, slide, x, y, w, h, color="panel", rounded=False):
308
+ shape = slide.shapes.add_shape(
309
+ MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
310
+ Inches(x), Inches(y), Inches(w), Inches(h))
311
+ shape.fill.solid()
312
+ color_value = self.colors.get(color, color)
313
+ shape.fill.fore_color.rgb = RGBColor.from_string(color_value)
314
+ shape.line.fill.background()
315
+ shape.shadow.inherit = False
316
+ # Drop the theme style reference: an empty effectLst is not enough for
317
+ # every renderer, and the theme shadow made thin rules look embossed.
318
+ style = shape._element.find(qn("p:style"))
319
+ if style is not None:
320
+ shape._element.remove(style)
321
+ return shape
322
+
323
+ def text(self, slide, value, x, y, w, h, size=21, color="text", bold=False,
324
+ anchor=None, align=None, tracking=None, font=None):
325
+ """Fit text conservatively; reject dense content instead of shrinking it."""
326
+ value = str(value)
327
+ if not value.strip():
328
+ return None
329
+ plain = value.replace("**", "")
330
+ lines = sum(max(1, math.ceil(sum(1 if ord(c) > 0x2E80 else .55 for c in line)
331
+ / (w * 72 / size))) for line in plain.split("\n"))
332
+ if lines * size * 1.25 + max(0, len(value.split("\n")) - 1) * 8 > h * 72:
333
+ raise ValueError(f"text exceeds layout capacity; split or shorten: {value[:60]}")
334
+ shape = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
335
+ frame = shape.text_frame
336
+ frame.word_wrap = True
337
+ if anchor is not None:
338
+ frame.vertical_anchor = anchor
339
+ frame.margin_left = frame.margin_right = frame.margin_top = frame.margin_bottom = 0
340
+ base_color = self.colors.get(color, color)
341
+ family = font or self.font
342
+ for index, line in enumerate(value.split("\n")):
343
+ paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
344
+ paragraph.space_after = Pt(8)
345
+ paragraph.line_spacing = 1.2
346
+ if align is not None:
347
+ paragraph.alignment = align
348
+ for part_index, part in enumerate(line.split("**")):
349
+ if not part:
350
+ continue
351
+ emphasis = part_index % 2 == 1
352
+ run = paragraph.add_run()
353
+ run.text = part
354
+ run.font.name, run.font.size = family, Pt(size)
355
+ run.font.bold = bold or emphasis
356
+ if tracking is not None:
357
+ run._r.get_or_add_rPr().set("spc", str(int(tracking)))
358
+ run.font.color.rgb = RGBColor.from_string(
359
+ self.colors["accent"] if emphasis else base_color)
360
+ east_asian = run._r.get_or_add_rPr().makeelement(qn("a:ea"), {"typeface": family})
361
+ run._r.get_or_add_rPr().append(east_asian)
362
+ return shape
363
+
364
+ def picture(self, slide, filename, x, y, w, h, crop=None):
365
+ with Image.open(self.base / filename) as source:
366
+ picture = ImageOps.exif_transpose(source).convert("RGB")
367
+ if crop is not None:
368
+ if (len(crop) != 4 or any(type(v) is not int for v in crop)
369
+ or not 0 <= crop[0] < crop[2] <= picture.width
370
+ or not 0 <= crop[1] < crop[3] <= picture.height):
371
+ raise ValueError(f"invalid pixel crop for image: {filename}")
372
+ picture = picture.crop(tuple(crop))
373
+ return self._picture_image(slide, picture, x, y, w, h)
374
+
375
+ def _picture_image(self, slide, picture, x, y, w, h):
376
+ payload = io.BytesIO()
377
+ picture.save(payload, "PNG")
378
+ payload.seek(0)
379
+ ratio = min(w / picture.width, h / picture.height)
380
+ width, height = picture.width * ratio, picture.height * ratio
381
+ left, top = x + (w - width) / 2, y + (h - height) / 2
382
+ slide.shapes.add_picture(payload, Inches(left), Inches(top), Inches(width), Inches(height))
383
+ return left, top, ratio
384
+
385
+ def _load(self, filename):
386
+ with Image.open(self.base / filename) as source:
387
+ return ImageOps.exif_transpose(source).convert("RGB")
388
+
389
+ def artwork(self, slide, filename, x, y, w, h, *, radius=0.0,
390
+ scrim=None, shadow=False, dpi=160, treatment=None, mask=None):
391
+ """Place a picture as designed artwork: crop-to-fill, never letterboxed.
392
+
393
+ ``radius`` rounds the corners (ratio of the short edge), ``scrim`` is a
394
+ media.apply_scrim kwargs dict and ``shadow`` adds a soft outer shadow.
395
+ """
396
+ picture = media.cover(self._load(filename),
397
+ (max(1, int(w * dpi)), max(1, int(h * dpi))))
398
+ if mask == "arch":
399
+ picture = media.arch_mask(picture)
400
+ elif mask == "circle":
401
+ picture = media.circle_mask(picture)
402
+ if treatment == "mono":
403
+ picture = ImageOps.grayscale(picture).convert("RGB")
404
+ elif treatment == "duotone":
405
+ picture = media.duotone(picture, dark=self.colors["bg"],
406
+ light="E8F6FA", strength=0.85)
407
+ elif treatment == "halftone":
408
+ picture = media.halftone(picture, spacing=6)
409
+ elif treatment == "glitch":
410
+ picture = media.glitch(picture, shift=5)
411
+ if scrim:
412
+ picture = media.apply_scrim(picture, **scrim)
413
+ if radius:
414
+ picture = media.rounded(picture, radius)
415
+ payload = io.BytesIO()
416
+ picture.save(payload, "PNG")
417
+ payload.seek(0)
418
+ shape = slide.shapes.add_picture(payload, Inches(x), Inches(y), Inches(w), Inches(h))
419
+ if shadow:
420
+ _soft_shadow(shape)
421
+ return shape
422
+
423
+ def _bleed_image(self, filename, *, scrim=None, color=None,
424
+ start=0.0, end=0.82, gamma=1.2, opacity=0.94,
425
+ vignette=0.0, focus=(0.5, 0.5), veil=0.0, grain=0.03):
426
+ picture = media.cover(self._load(filename), (1920, 1080), centering=focus)
427
+ tone = (media.parse_color(color) if color
428
+ else media.sample_ink(picture, mix=0.45, mix_color=self.colors["bg"]))
429
+ if veil:
430
+ picture = media.veil(picture, color=tone, opacity=veil)
431
+ if scrim:
432
+ picture = media.apply_scrim(picture, color=tone, direction=scrim,
433
+ start=start, end=end, gamma=gamma,
434
+ opacity=opacity)
435
+ if vignette:
436
+ picture = media.vignette(picture, strength=vignette)
437
+ if grain:
438
+ picture = media.grain(picture, grain)
439
+ return picture
440
+
441
+ def full_bleed(self, slide, filename, *, scrim=None, color=None,
442
+ start=0.0, end=0.82, gamma=1.2, opacity=0.94,
443
+ vignette=0.0, focus=(0.5, 0.5), veil=0.0, grain=0.03):
444
+ """A 16:9 picture that covers the whole slide and melts into a color."""
445
+ picture = self._bleed_image(filename, scrim=scrim, color=color, start=start,
446
+ end=end, gamma=gamma, opacity=opacity,
447
+ vignette=vignette, focus=focus, veil=veil,
448
+ grain=grain)
449
+ self.add_full(slide, picture)
450
+ tone = (color if isinstance(color, str) and len(color) == 6
451
+ else media.to_hex(media.sample_ink(picture, mix=0.45,
452
+ mix_color=self.colors["bg"])))
453
+ return tone.upper()
454
+
455
+ def blank(self, page, index):
456
+ """A slide without the standard title band (full-bleed compositions)."""
457
+ slide = self.prs.slides.add_slide(self.prs.slide_layouts[6])
458
+ slide.background.fill.solid()
459
+ slide.background.fill.fore_color.rgb = RGBColor.from_string(self.colors["bg"])
460
+ slide.notes_slide.notes_text_frame.text = page.get("notes", "")
461
+ return slide
462
+
463
+ def hero_slide(self, slide, page):
464
+ """Full-bleed image + gradient scrim + oversized headline."""
465
+ ink = page.get("ink", "light")
466
+ side = page.get("scrim", "left")
467
+ # Dark text needs a light veil; light text needs the picture's own dark
468
+ # tone. Sampling a dark scrim under dark text is unreadable by design.
469
+ color = page.get("scrim_color")
470
+ if color is None and ink == "dark":
471
+ color = "F7FBFF"
472
+ self.full_bleed(slide, page["image"], scrim=side,
473
+ color=color,
474
+ start=0.16 if side == "bottom" else 0.0,
475
+ end=page.get("scrim_end", 1.0 if side == "bottom" else 0.84),
476
+ opacity=page.get("scrim_opacity",
477
+ 0.90 if ink == "dark" else (0.96 if side == "bottom" else 0.95)),
478
+ vignette=page.get("vignette", 0.16),
479
+ veil=page.get("veil", 0.42 if ink == "dark" else 0.0))
480
+ light = ink == "light"
481
+ title_color = "FFFFFF" if light else self.colors["text"]
482
+ muted = "CBD9EA" if light else self.colors["muted"]
483
+ accent = "7FE3F0" if light else self.colors["accent"]
484
+ if side == "bottom":
485
+ x, y, w = 0.95, 3.35, 11.45
486
+ elif side == "right":
487
+ x, y, w = 6.55, 1.95, 6.2
488
+ else:
489
+ x, y, w = 0.95, 1.95, 6.6
490
+ kicker = page.get("kicker")
491
+ if kicker:
492
+ self.text(slide, str(kicker).upper(), x, y - 0.55, w, 0.32, 12, accent, True, tracking=160)
493
+ self.box(slide, x, y - 0.16, 0.62, 0.045, accent)
494
+ title = self.text(slide, page["title"], x, y, w, 2.15,
495
+ page.get("title_size", 42 if side != "bottom" else 38),
496
+ title_color, True, font=self.head_font)
497
+ if title is not None:
498
+ title.name = "title"
499
+ if page.get("body"):
500
+ self.text(slide, page["body"], x, y + 2.28, w, 1.35, 19, muted)
501
+ meta = page.get("meta") or self.meta.get("author", "")
502
+ if meta:
503
+ self.text(slide, meta, x, 6.88 if side == "bottom" else 6.55, w, 0.35,
504
+ 12, muted)
505
+ if page.get("page_number", False):
506
+ self.text(slide, f"{page.get('index', '')}", 12.0, 7.02, 0.7, 0.3, 11, muted)
507
+
508
+ def overlay_slide(self, slide, page):
509
+ """Full-bleed image + one-side scrim panel with title, body and stats."""
510
+ side = page.get("side", "left")
511
+ ink = page.get("ink", "light")
512
+ self.full_bleed(slide, page["image"], scrim=side,
513
+ color=page.get("scrim_color"),
514
+ end=page.get("scrim_end", 0.60),
515
+ opacity=page.get("scrim_opacity", 0.93),
516
+ vignette=0.12)
517
+ light = ink == "light"
518
+ title_color = "FFFFFF" if light else self.colors["text"]
519
+ body_color = "DCE8F6" if light else self.colors["text"]
520
+ muted = "AFC3DA" if light else self.colors["muted"]
521
+ accent = "7FE3F0" if light else self.colors["accent"]
522
+ x = 0.95 if side != "right" else 7.05
523
+ w = 5.35
524
+ top = 1.55
525
+ if page.get("kicker"):
526
+ self.text(slide, str(page["kicker"]).upper(), x, top, w, 0.3, 11, accent, True)
527
+ top += 0.42
528
+ title = self.text(slide, page["title"], x, top, w, 1.35,
529
+ page.get("title_size", 32), title_color, True,
530
+ font=self.head_font)
531
+ if title is not None:
532
+ title.name = "title"
533
+ cursor = top + 1.62
534
+ if page.get("body"):
535
+ self.text(slide, page["body"], x, cursor, w, 1.35, 17, body_color)
536
+ cursor += 1.05
537
+ bullets = page.get("bullets") or []
538
+ for position, bullet in enumerate(bullets[:4]):
539
+ y = cursor + position * 0.62
540
+ self.box(slide, x, y + 0.1, 0.1, 0.1, accent)
541
+ self.text(slide, bullet, x + 0.28, y, w - 0.28, 0.5, 15, body_color)
542
+ stats = page.get("stats") or []
543
+ if stats:
544
+ stat_top = 6.05
545
+ span = w / len(stats)
546
+ for position, stat in enumerate(stats[:3]):
547
+ sx = x + position * span
548
+ self.text(slide, str(stat.get("value", "")), sx, stat_top, span - 0.2, 0.55,
549
+ 26, accent, True)
550
+ self.text(slide, str(stat.get("label", "")), sx, stat_top + 0.5, span - 0.2,
551
+ 0.3, 11, muted)
552
+ if page.get("source"):
553
+ self.text(slide, page["source"], x, 6.85, w, 0.28, 10, muted)
554
+
555
+ def wall_slide(self, slide, page):
556
+ """Staggered 2–4 image wall: equal widths, uneven heights, soft depth."""
557
+ items = page["items"]
558
+ count = len(items)
559
+ gap = 0.3
560
+ width = (12.1 - gap * (count - 1)) / count
561
+ offsets = (0.0, 0.62, 0.26, 0.84)
562
+ for index, item in enumerate(items):
563
+ top = 1.92 + offsets[index % len(offsets)]
564
+ height = 6.35 - top - 0.5
565
+ left = 0.6 + index * (width + gap)
566
+ self.artwork(slide, item["image"], left, top, width, height,
567
+ radius=0.055, shadow=True)
568
+ self.text(slide, item["title"], left, top + height + 0.16, width, 0.3,
569
+ 15, "text", True)
570
+ if item.get("body"):
571
+ self.text(slide, item["body"], left, top + height + 0.46, width, 0.3,
572
+ 11, "muted")
573
+
574
+
575
+ # ── editorial system: mesh backgrounds, type art, layered compositions ──
576
+
577
+ def _dark(self) -> bool:
578
+ return self.meta.get("theme", "midnight") == "midnight"
579
+
580
+ def _blend(self, first, second, ratio: float) -> str:
581
+ a, b = media.parse_color(first), media.parse_color(second)
582
+ return media.to_hex(tuple(int(round(x * (1 - ratio) + y * ratio))
583
+ for x, y in zip(a, b)))
584
+
585
+ def _mesh_colors(self) -> list[str]:
586
+ accent, background = self.colors["accent"], self.colors["bg"]
587
+ if self._dark():
588
+ return [background, self._blend(background, accent, 0.14),
589
+ self._blend(accent, background, 0.70),
590
+ self._blend(background, "000000", 0.55)]
591
+ return [background, self._blend(background, accent, 0.05),
592
+ self._blend(accent, "FFFFFF", 0.78),
593
+ self._blend(background, "FFFFFF", 0.58)]
594
+
595
+ def background_image(self, *, grid: bool = False, seed: int = 7,
596
+ palette: list[str] | None = None):
597
+ image = media.mesh_gradient((1920, 1080), palette or self._mesh_colors(),
598
+ seed=seed, blur=170)
599
+ if grid:
600
+ image = media.rule_grid(image, spacing=88,
601
+ color="FFFFFF" if self._dark() else "0B1324",
602
+ opacity=0.035)
603
+ image = media.vignette(image, 0.30 if self._dark() else 0.10)
604
+ return media.grain(image, 0.028, seed=seed)
605
+
606
+ def add_full(self, slide, picture):
607
+ # Full-bleed art has no transparency; JPEG keeps a 9-page deck small.
608
+ return self._add_picture(slide, picture, 0, 0, 13.333333, 7.5, jpeg=True)
609
+
610
+ def _add_picture(self, slide, picture, x, y, w, h, shadow=False, jpeg=False):
611
+ payload = io.BytesIO()
612
+ if jpeg:
613
+ picture.convert("RGB").save(payload, "JPEG", quality=90)
614
+ else:
615
+ picture.save(payload, "PNG")
616
+ payload.seek(0)
617
+ shape = slide.shapes.add_picture(payload, Inches(x), Inches(y),
618
+ Inches(w), Inches(h))
619
+ if shadow:
620
+ _soft_shadow(shape)
621
+ return shape
622
+
623
+ def type_art(self, slide, text, box, *, mode="gradient", image=None,
624
+ opacity: float = 1.0, color=None):
625
+ """Place decorative display type (numerals / short words) inside a box."""
626
+ font_size = 520
627
+ if isinstance(image, str):
628
+ image = self._load(image)
629
+ accent = color or self.colors["accent"]
630
+ if mode == "outline":
631
+ art = media.text_art(text, font_size, stroke=max(3, font_size // 90),
632
+ stroke_color=accent)
633
+ elif mode == "image" and image is not None:
634
+ art = media.text_art(text, font_size, image=image, outline=2,
635
+ outline_color=self.colors["accent"])
636
+ else:
637
+ art = media.text_art(text, font_size,
638
+ fill=[accent, self._blend(accent, "FFFFFF", 0.55)])
639
+ if opacity < 1:
640
+ channel = art.getchannel("A").point(lambda value: int(value * opacity))
641
+ art.putalpha(channel)
642
+ x, y, w, h = box
643
+ ratio = min(w / art.width, h / art.height)
644
+ width, height = art.width * ratio, art.height * ratio
645
+ return self._add_picture(slide, art, x + (w - width) / 2,
646
+ y + (h - height) / 2, width, height)
647
+
648
+ def section_number_slide(self, slide, page):
649
+ dark = self._dark()
650
+ index = page.get("index", 1)
651
+ self.add_full(slide, self.background_image(grid=True, seed=index))
652
+ number = str(page.get("number", f"{index:02d}"))
653
+ art_mode = page.get("art", "gradient")
654
+ self.type_art(slide, number, (-0.55, 1.05, 6.3, 5.45),
655
+ mode=art_mode,
656
+ image=page.get("image") if art_mode == "image" else None,
657
+ opacity=page.get("art_opacity", 0.92))
658
+ text_color = self.colors["text"]
659
+ muted = "AFC3DA" if dark else self.colors["muted"]
660
+ accent = "7FE3F0" if dark else self.colors["accent"]
661
+ x, w = 6.55, 6.05
662
+ kicker = str(page.get("kicker", f"SECTION {number}")).upper()
663
+ self.text(slide, kicker, x, 1.42, w, 0.3, 12, accent, True, tracking=180)
664
+ self.text(slide, page["title"], x, 1.86, w, 2.0,
665
+ page.get("title_size", 40), text_color, True, font=self.head_font)
666
+ self.box(slide, x, 4.06, 0.72, 0.05, accent)
667
+ cursor = 4.3
668
+ if page.get("body"):
669
+ self.text(slide, page["body"], x, cursor, w, 0.9, 17, muted)
670
+ cursor += 0.95
671
+ for position, item in enumerate((page.get("items") or [])[:4]):
672
+ y = cursor + position * 0.5
673
+ self.text(slide, f"{position + 1:02d}", x, y, 0.5, 0.26, 11, accent, True)
674
+ self.text(slide, item, x + 0.55, y - 0.02, w - 0.55, 0.34, 15, text_color)
675
+ if page.get("transition"):
676
+ self.text(slide, page["transition"], x, 6.75, w, 0.3, 12, muted)
677
+ total = page.get("sections")
678
+ if isinstance(total, int) and total > 1:
679
+ current = int(page.get("section_no", 1))
680
+ for dot in range(total):
681
+ filled = dot == current - 1
682
+ self.box(slide, 11.35 + dot * 0.28, 6.86, 0.12, 0.12,
683
+ accent if filled else ("3A4E66" if dark else "D9E2EE"))
684
+
685
+ def statement_slide(self, slide, page):
686
+ side = page.get("scrim", "left")
687
+ ink = page.get("ink", "light")
688
+ color = page.get("scrim_color")
689
+ if color is None and ink == "dark":
690
+ color = "F7FBFF"
691
+ self.full_bleed(slide, page["image"], scrim=side, color=color,
692
+ end=page.get("scrim_end", 0.82),
693
+ opacity=page.get("scrim_opacity",
694
+ 0.90 if ink == "dark" else 0.94),
695
+ vignette=0.15,
696
+ veil=page.get("veil", 0.42 if ink == "dark" else 0.0))
697
+ text_light = ink == "light"
698
+ title_color = "FFFFFF" if text_light else self.colors["text"]
699
+ muted = "CBD9EA" if text_light else self.colors["muted"]
700
+ accent = "7FE3F0" if text_light else self.colors["accent"]
701
+ left_text = side == "left"
702
+ if side == "bottom":
703
+ x, w, y = 0.95, 9.6, 3.55
704
+ art_box = (9.55, 1.05, 3.3, 2.55)
705
+ elif left_text:
706
+ x, w, y = 0.95, 5.9, 1.95
707
+ art_box = (-0.65, 1.25, 6.0, 4.95)
708
+ else:
709
+ x, w, y = 7.0, 5.45, 1.95
710
+ art_box = (8.4, 1.25, 6.0, 4.95)
711
+ self.type_art(slide, str(page.get("art_text", page.get("number", ""))),
712
+ art_box, mode=page.get("art", "gradient"),
713
+ image=page["image"] if page.get("art") == "image" else None,
714
+ opacity=page.get("art_opacity", 0.20))
715
+ if page.get("kicker"):
716
+ self.text(slide, str(page["kicker"]).upper(), x, y - 0.52, w, 0.3, 12, accent, True, tracking=160)
717
+ self.box(slide, x, y - 0.16, 0.62, 0.045, accent)
718
+ self.text(slide, page["title"], x, y, w, 2.0,
719
+ page.get("title_size", 40 if side != "bottom" else 36),
720
+ title_color, True, font=self.head_font)
721
+ if page.get("body"):
722
+ self.text(slide, page["body"], x, y + 2.2, w, 1.3, 18, muted)
723
+ meta = page.get("meta") or self.meta.get("author", "")
724
+ if meta:
725
+ self.text(slide, meta, x, 6.78, w, 0.32, 12, muted)
726
+
727
+ def stack_slide(self, slide, page):
728
+ items = page["items"]
729
+ count = len(items)
730
+ layouts = {
731
+ 2: ((0.85, 2.0, 5.5, 3.9), (6.75, 2.4, 5.5, 3.9), (-4, 3)),
732
+ 3: ((0.65, 2.05, 4.15, 3.7), (4.6, 1.85, 4.15, 3.95), (8.55, 2.35, 4.15, 3.65),
733
+ (-5, 2, -3)),
734
+ 4: ((0.55, 2.1, 3.05, 3.1), (3.85, 1.9, 3.05, 3.35),
735
+ (7.15, 2.2, 3.05, 3.15), (10.45, 1.95, 3.05, 3.3),
736
+ (-6, 2, -4, 3)),
737
+ }
738
+ boxes = layouts[count][:count]
739
+ rotations = layouts[count][-1]
740
+ for index, item in enumerate(items):
741
+ x, y, w, h = boxes[index]
742
+ shape = self.artwork(slide, item["image"], x, y, w, h,
743
+ radius=0.035, shadow=True,
744
+ treatment=item.get("treatment"),
745
+ mask=item.get("mask"))
746
+ shape.rotation = rotations[index]
747
+ if item.get("title"):
748
+ self.text(slide, item["title"], x + 0.05, 6.42,
749
+ w - 0.1, 0.3, 13, "text", True)
750
+ if item.get("body"):
751
+ self.text(slide, item["body"], x + 0.05, 6.72,
752
+ w - 0.1, 0.26, 9, "muted")
753
+
754
+ def diagonal_slide(self, slide, page):
755
+ left, right = page["images"]
756
+ background = media.diagonal_split(
757
+ self._load(left), self._load(right), (1920, 1080),
758
+ split=page.get("split", 0.58), lean=page.get("lean", 0.16),
759
+ seam=page.get("seam", self.colors["accent"]), seam_width=5)
760
+ background = media.apply_scrim(background, color="0B1224",
761
+ direction="bottom", start=0.42, end=1.0,
762
+ gamma=1.4, opacity=0.78)
763
+ background = media.grain(background, 0.03)
764
+ self.add_full(slide, background)
765
+ x, w = 0.95, 8.6
766
+ if page.get("kicker"):
767
+ self.text(slide, str(page["kicker"]).upper(), x, 4.02, w, 0.3, 12, "7FE3F0", True)
768
+ self.text(slide, page["title"], x, 4.35, w, 1.5,
769
+ page.get("title_size", 38), "FFFFFF", True)
770
+ if page.get("body"):
771
+ self.text(slide, page["body"], x, 5.92, 6.5, 0.55, 16, "CBD9EA")
772
+ labels = page.get("labels") or []
773
+ for index, label in enumerate(labels[:2]):
774
+ lx = 0.95 if index == 0 else 10.0
775
+ self.box(slide, lx, 6.55, 0.34, 0.05, "7FE3F0")
776
+ self.text(slide, label, lx + 0.48, 6.42, 2.9, 0.3, 12, "FFFFFF", True)
777
+
778
+ def glass_slide(self, slide, page):
779
+ ink = page.get("ink", "light" if self._dark() else "dark")
780
+ background = self._bleed_image(page["image"], scrim=None, vignette=0.10,
781
+ grain=page.get("grain", 0.03))
782
+ self.add_full(slide, background)
783
+ x, y, w, h = 7.55, 1.35, 5.15, 5.05
784
+ scale = 1920 / 13.333333
785
+ dark = self._dark()
786
+ panel = media.frosted(background, (int(x * scale), int(y * scale),
787
+ int((x + w) * scale), int((y + h) * scale)),
788
+ radius=0.055,
789
+ tint=page.get("glass_tint",
790
+ self.colors["bg"] if dark else "FFFFFF"),
791
+ opacity=page.get("glass_opacity", 0.34 if dark else 0.22),
792
+ blur=30, border=0.35)
793
+ self._add_picture(slide, panel, x, y, w, h)
794
+ light = ink == "light"
795
+ title_color = "FFFFFF" if light else self.colors["text"]
796
+ body_color = "DCE8F6" if light else self.colors["text"]
797
+ muted = "AFC3DA" if light else self.colors["muted"]
798
+ accent = "7FE3F0" if light else self.colors["accent"]
799
+ tx, tw = x + 0.38, w - 0.76
800
+ self.box(slide, tx, y + 0.34, 0.55, 0.045, accent)
801
+ cursor = y + 0.55
802
+ if page.get("kicker"):
803
+ self.text(slide, str(page["kicker"]).upper(), tx, cursor, tw, 0.3, 11, accent, True)
804
+ cursor += 0.38
805
+ self.text(slide, page["title"], tx, cursor, tw, 1.35,
806
+ page.get("title_size", 29), title_color, True, font=self.head_font)
807
+ cursor += 1.42
808
+ if page.get("body"):
809
+ self.text(slide, page["body"], tx, cursor, tw, 1.15, 16, body_color)
810
+ cursor += 1.2
811
+ stats = page.get("stats") or []
812
+ if stats:
813
+ stat_y = y + h - 1.35
814
+ span = tw / len(stats)
815
+ for index, stat in enumerate(stats[:3]):
816
+ sx = tx + index * span
817
+ self.text(slide, str(stat.get("value", "")), sx, stat_y,
818
+ span - 0.15, 0.6, 26, accent, True)
819
+ self.text(slide, str(stat.get("label", "")), sx, stat_y + 0.55,
820
+ span - 0.15, 0.3, 10, muted)
821
+ if page.get("source"):
822
+ self.text(slide, page["source"], x + 0.38, y + h - 0.32, w - 0.76, 0.24,
823
+ 9, muted)
824
+
825
+ def quote_slide(self, slide, page):
826
+ dark = self._dark()
827
+ background = self.background_image(grid=page.get("grid", False),
828
+ seed=page.get("index", 1))
829
+ accent = self.colors["accent"]
830
+ layer = Image.new("RGBA", background.size, (0, 0, 0, 0))
831
+ font = media.display_font(560)
832
+ if font is not None:
833
+ ImageDraw.Draw(layer).text((70, 20), "“", font=font,
834
+ fill=media.parse_color(accent) + (52,))
835
+ background = Image.alpha_composite(background.convert("RGBA"), layer)
836
+ self.add_full(slide, background)
837
+ text_color = self.colors["text"]
838
+ muted = "AFC3DA" if dark else self.colors["muted"]
839
+ self.text(slide, page["quote"], 1.55, 2.05, 10.2, 2.7,
840
+ page.get("quote_size", 34), text_color, True,
841
+ align=PP_ALIGN.CENTER, font=self.head_font)
842
+ self.box(slide, 5.95, 5.12, 1.45, 0.04, accent)
843
+ if page.get("by"):
844
+ self.text(slide, page["by"], 1.55, 5.4, 10.2, 0.4, 15, muted,
845
+ align=PP_ALIGN.CENTER)
846
+ if page.get("title"):
847
+ self.text(slide, page["title"], 1.55, 1.35, 10.2, 0.42, 11,
848
+ accent, True, align=PP_ALIGN.CENTER)
849
+
850
+ def kpi_slide(self, slide, page):
851
+ dark = self._dark()
852
+ background = self.background_image(grid=False, seed=page.get("index", 1))
853
+ background = media.dot_grid(background, spacing=26,
854
+ color="FFFFFF" if dark else "0B1324",
855
+ opacity=0.07)
856
+ self.add_full(slide, background)
857
+ text_color = self.colors["text"]
858
+ muted = "AFC3DA" if dark else self.colors["muted"]
859
+ accent = "7FE3F0" if dark else self.colors["accent"]
860
+ self.text(slide, str(page.get("kicker", "")).upper(), 0.95, 1.0, 6.0, 0.3,
861
+ 12, accent, True, tracking=160)
862
+ self.text(slide, page["title"], 0.95, 1.4, 10.6, 1.2,
863
+ page.get("title_size", 32), text_color, True, font=self.head_font)
864
+ stats = page["stats"]
865
+ top = 3.0
866
+ span = 12.0 / len(stats)
867
+ for index, stat in enumerate(stats):
868
+ x = 0.95 + index * span
869
+ raw = str(stat.get("value", ""))
870
+ size = 64 if len(raw) <= 4 else 50 if len(raw) <= 7 else 42
871
+ self.text(slide, raw, x, top, span - 0.6, 1.15, size, accent, True)
872
+ self.text(slide, str(stat.get("label", "")), x, top + 1.18, span - 0.6,
873
+ 0.34, 14, text_color)
874
+ if stat.get("note"):
875
+ self.text(slide, str(stat["note"]), x, top + 1.58, span - 0.6,
876
+ 0.6, 11, muted)
877
+ share = stat.get("share")
878
+ if isinstance(share, (int, float)):
879
+ ratio = min(1.0, max(0.0, float(share)))
880
+ self.box(slide, x, top + 2.28, span - 0.8, 0.05,
881
+ "2A3E55" if dark else "D9E2EE")
882
+ self.box(slide, x, top + 2.28,
883
+ max(0.2, (span - 0.8) * ratio), 0.05, accent)
884
+ if index:
885
+ self.box(slide, x - 0.35, top + 0.05, 0.012, 1.7, "3A4E66" if dark else "D9E2EE")
886
+ if page.get("source"):
887
+ self.text(slide, page["source"], 0.95, 6.65, 11.4, 0.26, 10, muted)
888
+
889
+ def transition(self, slide, kind: str):
890
+ element = slide._element
891
+ for old in element.findall(qn("p:transition")):
892
+ element.remove(old)
893
+ transition = element.makeelement(qn("p:transition"), {})
894
+ transition.append(element.makeelement(qn(f"p:{kind}"), {}))
895
+ extension = element.find(qn("p:extLst"))
896
+ if extension is not None:
897
+ extension.addprevious(transition)
898
+ else:
899
+ element.append(transition)
900
+
901
+
902
+ def _load_alpha(self, filename):
903
+ with Image.open(self.base / filename) as source:
904
+ return ImageOps.exif_transpose(source).convert("RGBA")
905
+
906
+ def _set_fill_alpha(self, shape, percent: float) -> None:
907
+ fill = shape.fill._xPr.find(qn("a:solidFill"))
908
+ if fill is None:
909
+ return
910
+ color = fill.find(qn("a:srgbClr"))
911
+ if color is None:
912
+ return
913
+ alpha = color.makeelement(qn("a:alpha"), {"val": str(int(percent * 100000))})
914
+ color.append(alpha)
915
+
916
+ def cutout_slide(self, slide, page):
917
+ dark = self._dark()
918
+ index = page.get("index", 1)
919
+ self.add_full(slide, self.background_image(grid=True, seed=index))
920
+ side = page.get("side", "right")
921
+ subject = self._load_alpha(page["image"])
922
+ if page.get("flip"):
923
+ subject = ImageOps.mirror(subject)
924
+ subject = media.defringe(subject, 1)
925
+ box = subject.getbbox()
926
+ if box:
927
+ subject = subject.crop(box)
928
+ height = float(page.get("height", 6.4))
929
+ width = height * subject.width / subject.height
930
+ x = 12.85 - width - (0.6 if side == "right" else 0) if side == "right" else 0.45
931
+ y = 7.55 - height
932
+ # A quiet ring ties the cutout to the accent system.
933
+ ring_x = x + width * 0.02 - 0.35
934
+ ring = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(ring_x), Inches(y + 0.5),
935
+ Inches(width + 0.7), Inches(width + 0.7))
936
+ ring.fill.background()
937
+ ring.line.color.rgb = RGBColor.from_string(self.colors["accent"])
938
+ ring.line.width = Pt(1.1)
939
+ ring.shadow.inherit = False
940
+ text_light = True
941
+ title_color = "FFFFFF"
942
+ muted = "B9CCE2"
943
+ accent = "7FE3F0"
944
+ tx, tw = (0.95, 8.55) if side == "right" else (4.55, 7.9)
945
+ if page.get("kicker"):
946
+ self.text(slide, str(page["kicker"]).upper(), tx, 1.45, tw, 0.3, 12, accent,
947
+ True, tracking=160)
948
+ self.box(slide, tx, 1.82, 0.62, 0.045, accent)
949
+ self.text(slide, page["title"], tx, 2.02, tw, 2.4,
950
+ page.get("title_size", 46), title_color, True, font=self.head_font)
951
+ if page.get("body"):
952
+ self.text(slide, page["body"], tx, 4.62, tw - 0.4, 1.3, 18, muted)
953
+ stats = page.get("stats") or []
954
+ for position, stat in enumerate(stats[:3]):
955
+ sx = tx + position * 2.25
956
+ self.text(slide, str(stat.get("value", "")), sx, 6.02, 1.9, 0.55, 28, accent, True)
957
+ self.text(slide, str(stat.get("label", "")), sx, 6.55, 1.9, 0.26, 10, muted)
958
+ subject = media.drop_shadow(subject, blur=26, offset=(0, 16), opacity=0.34)
959
+ self._add_picture(slide, subject, x, y, width, height)
960
+
961
+ def annotated_slide(self, slide, page):
962
+ background = self._bleed_image(page["image"], scrim="bottom", end=0.42,
963
+ opacity=0.55, vignette=0.22, grain=0.025,
964
+ veil=0.14)
965
+ self.add_full(slide, background)
966
+ self.text(slide, str(page.get("kicker", "")).upper(), 0.95, 0.85, 6.0, 0.3,
967
+ 12, "7FE3F0", True, tracking=160)
968
+ self.text(slide, page["title"], 0.95, 1.2, 9.0, 0.9, 30, "FFFFFF", True,
969
+ font=self.head_font)
970
+ accent = self.colors["accent"]
971
+ for index, point in enumerate(page["points"], 1):
972
+ px, py = float(point["x"]) * 13.333, float(point["y"]) * 7.5
973
+ dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(px - 0.11), Inches(py - 0.11),
974
+ Inches(0.22), Inches(0.22))
975
+ dot.fill.solid()
976
+ dot.fill.fore_color.rgb = RGBColor.from_string("FFFFFF")
977
+ dot.line.color.rgb = RGBColor.from_string(accent)
978
+ dot.line.width = Pt(2)
979
+ dot.shadow.inherit = False
980
+ self.text(slide, f"{index:02d}", px - 0.09, py - 0.075, 0.2, 0.2, 7, "0B1324", True,
981
+ align=PP_ALIGN.CENTER)
982
+ right = px < 6.8
983
+ label_w = 3.1
984
+ lx = px + 0.45 if right else px - 0.45 - label_w
985
+ ly = min(max(py - 0.42, 0.55), 6.3)
986
+ line = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
987
+ Inches(px), Inches(py),
988
+ Inches(lx if right else lx + label_w),
989
+ Inches(ly + 0.3))
990
+ line.line.color.rgb = RGBColor.from_string(accent)
991
+ line.line.width = Pt(1.1)
992
+ chip = self.box(slide, lx, ly, label_w, 0.62, "0B1324", rounded=True)
993
+ self._set_fill_alpha(chip, 0.72)
994
+ self.text(slide, point["text"], lx + 0.18, ly + 0.12, label_w - 0.3, 0.42,
995
+ 12 if len(point["text"]) > 10 else 14, "FFFFFF", True)
996
+ if page.get("source"):
997
+ self.text(slide, page["source"], 0.95, 6.85, 11.4, 0.25, 10, "B9CCE2")
998
+
999
+ def arch_slide(self, slide, page):
1000
+ dark = self._dark()
1001
+ background = media.mesh_gradient((1920, 1080), [self.colors["bg"],
1002
+ self._blend(self.colors["bg"],
1003
+ self.colors["accent"], 0.10)],
1004
+ seed=page.get("index", 1), blur=220)
1005
+ background = media.grain(background, 0.03)
1006
+ self.add_full(slide, background)
1007
+ picture = media.cover(self._load(page["image"]), (900, 1100))
1008
+ picture = media.arch_mask(picture, radius_ratio=0.5)
1009
+ picture = media.drop_shadow(picture, blur=24, offset=(0, 16), opacity=0.45)
1010
+ self._add_picture(slide, picture, 0.75, 0.72, 5.15, 6.3)
1011
+ text_color = self.colors["text"]
1012
+ muted = "AFC3DA" if dark else self.colors["muted"]
1013
+ accent = "7FE3F0" if dark else self.colors["accent"]
1014
+ x, w = 6.65, 5.95
1015
+ if page.get("kicker"):
1016
+ self.text(slide, str(page["kicker"]).upper(), x, 1.55, w, 0.3, 12, accent, True,
1017
+ tracking=160)
1018
+ self.text(slide, page["title"], x, 1.95, w, 1.8, page.get("title_size", 36),
1019
+ text_color, True, font=self.head_font)
1020
+ self.box(slide, x, 3.92, 0.7, 0.05, accent)
1021
+ if page.get("body"):
1022
+ self.text(slide, page["body"], x, 4.18, w, 1.4, 17, muted)
1023
+ for position, item in enumerate((page.get("items") or [])[:3]):
1024
+ self.text(slide, item, x, 5.72 + position * 0.34, w, 0.3, 13, text_color)
1025
+
1026
+ def new(self, page, index):
1027
+ slide = self.prs.slides.add_slide(self.prs.slide_layouts[6])
1028
+ slide.background.fill.solid()
1029
+ slide.background.fill.fore_color.rgb = RGBColor.from_string(self.colors["bg"])
1030
+ title = self.text(slide, page.get("title", self.meta["title"]),
1031
+ .6, .38, 12.1, .95, 30, bold=True, font=self.head_font)
1032
+ if title is not None:
1033
+ title.name = "title"
1034
+ if page.get("subtitle"):
1035
+ subtitle = self.text(slide, page["subtitle"], .6, 1.35, 12.1, .65, 17, "muted")
1036
+ if subtitle is not None:
1037
+ subtitle.name = "subtitle"
1038
+ footer = self.text(slide, self.meta["title"], .6, 7.06, 10.8, .24, 10, "muted")
1039
+ if footer is not None:
1040
+ footer.name = "chrome:footer"
1041
+ number = self.text(slide, f"{index:02}", 12.0, 7.02, .7, .3, 12, "muted")
1042
+ if number is not None:
1043
+ number.name = "chrome:page"
1044
+ slide.notes_slide.notes_text_frame.text = page.get("notes", "")
1045
+ return slide
1046
+
1047
+ def _text_lines(self, value, width, size):
1048
+ return sum(max(1, math.ceil(sum(1 if ord(char) > 0x2E80 else .55 for char in line)
1049
+ / (width * 72 / size)))
1050
+ for line in str(value).split("\n"))
1051
+
1052
+ def _card_need(self, item, width):
1053
+ """Estimate the height a card needs for its copy, in inches."""
1054
+ body = self._text_lines(item.get("body", ""), width - .5, 20) * 20 * 1.25 / 72
1055
+ # title band + top pad (1.15) + bottom band for rule/foot (1.02)
1056
+ base = 1.15 + body + 1.02
1057
+ return max(2.6, min(4.55, base))
1058
+
1059
+ def card(self, slide, item, x, y, w, h, number=None):
1060
+ self.box(slide, x, y, w, h, rounded=True)
1061
+ offset = .25
1062
+ if number is not None:
1063
+ self.text(slide, f"{number:02}", x + .25, y + .25, w - .5, .55, 28, "accent", True)
1064
+ offset = 1.0
1065
+ self.text(slide, item["title"], x + .25, y + offset, w - .5, .85, 23, "accent", True)
1066
+ compact = h < 3.0
1067
+ numbered = number is not None
1068
+ body_height = h - offset - (1.55 if compact else (1.7 if numbered else 1.9))
1069
+ # Four-card pages intentionally use compact cards. Keep their body
1070
+ # copy readable while leaving enough vertical room for two wrapped
1071
+ # Chinese lines instead of allowing the strict capacity check to fail.
1072
+ body_size = 15 if body_height < 1.0 or w < 2.4 else 20
1073
+ # Un-numbered cards centre short copy; a numbered step card keeps the
1074
+ # body tight under its title so the number stays part of the header.
1075
+ body_y = y + offset + (1.0 if numbered else 0.85)
1076
+ self.text(slide, item.get("body", ""), x + .25, body_y,
1077
+ w - .5, body_height, body_size,
1078
+ anchor=None if numbered else MSO_ANCHOR.MIDDLE)
1079
+ # A restrained base rule anchors short copy instead of leaving a card
1080
+ # that looks like an unfilled template placeholder.
1081
+ rule_y = y + h - (0.38 if compact else 0.66)
1082
+ self.box(slide, x + .25, rule_y, min(w - .5, 1.0), .045, "accent")
1083
+ if item.get("foot") and not compact:
1084
+ self.text(slide, item["foot"], x + .25, y + h - .52, w - .5, .26, 11, "muted")
1085
+
1086
+ def compose(self, slide, page):
1087
+ layout = page["layout"]
1088
+ if layout == "image_hero":
1089
+ self.hero_slide(slide, page)
1090
+ elif layout == "section_number":
1091
+ self.section_number_slide(slide, page)
1092
+ elif layout == "image_statement":
1093
+ self.statement_slide(slide, page)
1094
+ elif layout == "split_diagonal":
1095
+ self.diagonal_slide(slide, page)
1096
+ elif layout == "glass_overlay":
1097
+ self.glass_slide(slide, page)
1098
+ elif layout == "quote_stage":
1099
+ self.quote_slide(slide, page)
1100
+ elif layout == "kpi_showcase":
1101
+ self.kpi_slide(slide, page)
1102
+ elif layout == "cutout_statement":
1103
+ self.cutout_slide(slide, page)
1104
+ elif layout == "image_annotated":
1105
+ self.annotated_slide(slide, page)
1106
+ elif layout == "image_arch":
1107
+ self.arch_slide(slide, page)
1108
+ elif layout == "image_overlay":
1109
+ self.overlay_slide(slide, page)
1110
+ elif layout == "image_wall":
1111
+ self.wall_slide(slide, page)
1112
+ elif layout in {"cover", "closing"}:
1113
+ image = page.get("image")
1114
+ width = 7.1 if image else 11.7
1115
+ self.text(slide, page.get("body", self.meta.get("subtitle", "")),
1116
+ .8, 2.35, width, 2.5, 32, "accent", True)
1117
+ self.text(slide, self.meta.get("author", ""), .8, 5.5, width, .7, 20, "muted")
1118
+ if image:
1119
+ self.picture(slide, image, 8.3, 1.95, 4.3, 4.75)
1120
+ elif layout in {"cards", "comparison"}:
1121
+ items = page["items"]
1122
+ columns = len(items) if len(items) <= 3 else 2
1123
+ rows = math.ceil(len(items) / columns)
1124
+ width = (12.1 - .25 * (columns - 1)) / columns
1125
+ takeaway = page.get("takeaway")
1126
+ area = 4.55 - (0.55 if takeaway else 0)
1127
+ slot = (area - .25 * (rows - 1)) / rows
1128
+ # Fit the card to its copy instead of stretching a short card into
1129
+ # an empty 4.5-inch shell; the group stays vertically centred.
1130
+ height = min(slot, max(self._card_need(item, width) for item in items))
1131
+ top = 2.05 + (area - (height * rows + .25 * (rows - 1))) / 2
1132
+ for index, item in enumerate(items):
1133
+ self.card(slide, item, .6 + (index % columns) * (width + .25),
1134
+ top + (index // columns) * (height + .25), width, height)
1135
+ if takeaway:
1136
+ self.box(slide, .6, 6.24, .06, .5, "accent")
1137
+ self.text(slide, takeaway, .88, 6.22, 11.7, 0.55, 15, "text", True)
1138
+ elif layout == "process":
1139
+ items = page["items"]
1140
+ width = (12.1 - .4 * (len(items) - 1)) / len(items)
1141
+ for index, item in enumerate(items):
1142
+ x = .6 + index * (width + .4)
1143
+ self.card(slide, item, x, 2.25, width, 4.1, index + 1)
1144
+ if index < len(items) - 1:
1145
+ shape = slide.shapes.add_shape(MSO_SHAPE.CHEVRON, Inches(x + width + .07),
1146
+ Inches(3.9), Inches(.25), Inches(.45))
1147
+ shape.fill.solid()
1148
+ shape.fill.fore_color.rgb = RGBColor.from_string(self.colors["accent"])
1149
+ shape.line.fill.background()
1150
+ elif layout == "image_split":
1151
+ self.artwork(slide, page["image"], .6, 2.05, 7.1, 4.55,
1152
+ radius=.05, shadow=True)
1153
+ self.box(slide, 8.0, 2.05, 4.7, 4.55, rounded=True)
1154
+ self.text(slide, page.get("body", ""), 8.3, 2.4, 4.1, 3.8, 22)
1155
+ elif layout == "image_grid":
1156
+ items = page["items"]
1157
+ if len(items) == 3:
1158
+ # Three equal tiles across, flat and square-cornered: one clean row.
1159
+ gap, top = .3, 2.02
1160
+ width = (12.1 - gap * 2) / 3
1161
+ for index, item in enumerate(items):
1162
+ x = .6 + index * (width + gap)
1163
+ self.artwork(slide, item["image"], x, top, width, 3.70, radius=0.0)
1164
+ self.text(slide, item["title"], x, 5.84, width, .34, 17, "accent", True)
1165
+ if item.get("body"):
1166
+ self.text(slide, item["body"], x, 6.22, width, .30, 12, "muted")
1167
+ else:
1168
+ rows = math.ceil(len(items) / 2)
1169
+ height = (4.55 - .2 * (rows - 1)) / rows
1170
+ for index, item in enumerate(items):
1171
+ x, y = .6 + (index % 2) * 6.2, 2.05 + (index // 2) * (height + .2)
1172
+ self.artwork(slide, item["image"], x, y, 5.9, height - .62,
1173
+ radius=.04, shadow=True)
1174
+ self.text(slide, item["title"], x, y + height - .48, 5.9, .45, 18, "accent", True)
1175
+ elif layout == "heat_bars":
1176
+ items = page["items"]
1177
+ fire_colors = {"大火": "8C2F22", "中火": "B4602A",
1178
+ "小火": "E2A35E", "微火": "A9744F"}
1179
+ levels = {"大火": 4, "中火": 3, "小火": 2, "微火": 1}
1180
+ total = float(page.get("total_minutes", 90))
1181
+ x0, x1 = 5.45, 11.95
1182
+ span = x1 - x0
1183
+ top = 2.52
1184
+ row_h = .82
1185
+ for i in range(len(items)):
1186
+ if i % 2 == 0:
1187
+ self.box(slide, .55, top + .04 + i * row_h, 11.9, row_h - .02, "F6EEE4")
1188
+ for tick in range(0, int(total), 15):
1189
+ x = x0 + span * tick / total
1190
+ self.box(slide, x, top - .10, .012, 4.10, "E4D2C0")
1191
+ self.text(slide, str(tick), x - .18, top - .38, .36, .20, 8, "muted",
1192
+ align=PP_ALIGN.CENTER)
1193
+ self.text(slide, "分钟", x1 + .02, top - .38, .5, .20, 8, "muted")
1194
+ starts = []
1195
+ cursor = 0.0
1196
+ for item in items:
1197
+ starts.append(cursor)
1198
+ cursor += float(item["minutes"])
1199
+ for i, item in enumerate(items):
1200
+ y = top + .14 + i * row_h
1201
+ fire = item["fire"]
1202
+ level = int(item.get("level", levels.get(fire, 2)))
1203
+ self.text(slide, item.get("number", f"{i + 1:02d}"), .78, y - .02,
1204
+ .55, .30, 14, "accent", True)
1205
+ self.text(slide, item["title"], 1.34, y - .04, 2.6, .40, 20, "text", True)
1206
+ duration = item.get("time", f"{int(float(item['minutes']))} min")
1207
+ self.text(slide, f"{fire} · {duration}", 1.36, y + .36, 2.3, .22, 10, "muted")
1208
+ self.box(slide, .62, y + .04, .035, .54, fire_colors[fire])
1209
+ minutes = float(item["minutes"])
1210
+ bar_w = max(.08, span * minutes / total)
1211
+ bar_x = x0 + span * starts[i] / total
1212
+ by = y + .18
1213
+ self.box(slide, bar_x, by, bar_w, .20, fire_colors[fire], rounded=True)
1214
+ body_x = bar_x
1215
+ body_w = x1 - bar_x
1216
+ if body_w < 2.6:
1217
+ body_x = max(x0, bar_x - 2.8)
1218
+ body_w = 2.8
1219
+ self.box(slide, bar_x, by - .04, .012, .28, "text")
1220
+ self.text(slide, item["body"], body_x, y + .43, body_w, .26, 10, "muted")
1221
+ if page.get("source"):
1222
+ self.text(slide, page["source"], .6, 6.70, 11.8, .20, 9, "muted")
1223
+ elif layout == "ingredient_split":
1224
+ items = page["items"]
1225
+ self.artwork(slide, page["image"], 5.55, 0, 7.78, 7.5, radius=0.0)
1226
+ self.box(slide, 0, 0, 5.55, 7.5, "140C08")
1227
+ self.box(slide, 5.54, 0, .012, 7.5, "B98A63")
1228
+ self.text(slide, page.get("kicker", "备料 / MISE EN PLACE"), .65, .55,
1229
+ 4.5, .22, 9, "B98A63", True)
1230
+ self.text(slide, "800", .60, 1.00, 2.6, 1.16, 64, "FFFFFF", True,
1231
+ font=self.head_font)
1232
+ self.text(slide, "g", 3.08, 1.48, .5, .4, 20, "B98A63", True)
1233
+ self.text(slide, page.get("fish_label", "草鱼(或黑鱼 750 g)"), .65, 2.18,
1234
+ 4.35, .25, 9.5, "E8D5C4")
1235
+ self.text(slide, "300", .60, 2.54, 2.6, 1.16, 64, "FFFFFF", True,
1236
+ font=self.head_font)
1237
+ self.text(slide, "g", 3.08, 3.02, .5, .4, 20, "B98A63", True)
1238
+ self.text(slide, page.get("acid_label", "老坛酸菜"), .65, 3.72,
1239
+ 4.35, .25, 9.5, "E8D5C4")
1240
+ self.text(slide, "8 : 3", 4.15, 1.10, 1.1, .34, 18, "B98A63", True,
1241
+ align=PP_ALIGN.RIGHT)
1242
+ self.text(slide, "鱼 : 酸菜", 4.15, 1.42, 1.1, .20, 8, "E8D5C4",
1243
+ align=PP_ALIGN.RIGHT)
1244
+ for i, item in enumerate(items):
1245
+ y = 4.05 + i * .38
1246
+ self.text(slide, item["label"], .65, y, .95, .22, 8, "B98A63", True)
1247
+ self.text(slide, item["value"], 1.65, y - .02, 3.55, .30, 9.5, "F4E9DC")
1248
+ if page.get("source"):
1249
+ self.text(slide, page["source"], .65, 6.52, 4.55, .52, 8.5, "C9B3A0")
1250
+ elif layout == "state_gallery":
1251
+ items = page["items"]
1252
+ self.box(slide, .60, 2.00, 7.15, 4.45, "B04A2A")
1253
+ self.artwork(slide, items[0]["image"], .70, 2.10, 6.95, 4.25, radius=0.0)
1254
+ self.text(slide, "01", .60, 6.48, .7, .40, 22, "accent", True)
1255
+ self.text(slide, items[0]["title"], 1.28, 6.50, 6.4, .32, 17, "text", True)
1256
+ self.text(slide, items[0]["body"], 1.28, 6.78, 6.4, .22, 11, "muted")
1257
+ for idx, (y, item) in enumerate(((2.00, items[1]), (4.52, items[2])), start=1):
1258
+ self.box(slide, 8.00, y, 4.70, 2.00, "B04A2A")
1259
+ self.artwork(slide, item["image"], 8.06, y + .06, 4.58, 1.88, radius=0.0)
1260
+ self.text(slide, f"0{idx+1}", 8.00, y + 2.04, .55, .28, 13, "accent", True)
1261
+ self.text(slide, item["title"], 8.52, y + 2.02, 4.0, .26, 12.5, "text", True)
1262
+ self.text(slide, item["body"], 8.52, y + 2.26, 4.0, .18, 8.8, "muted")
1263
+ elif layout == "state_band":
1264
+ items = page["items"]
1265
+ self.box(slide, 0, 0, 13.333, 1.18, "FBF6EE")
1266
+ self.text(slide, page["title"], .6, .28, 12.1, .52, 26, "text", True,
1267
+ font=self.head_font)
1268
+ if page.get("subtitle"):
1269
+ self.text(slide, page["subtitle"], .6, .82, 12.1, .26, 13, "muted")
1270
+ top, bottom = 1.18, 7.02
1271
+ h2 = (bottom - top - .04) / 2
1272
+ self.artwork(slide, items[0]["image"], 0, top, 8.2, bottom - top, radius=0.0)
1273
+ self.artwork(slide, items[1]["image"], 8.2, top, 5.133, h2, radius=0.0)
1274
+ self.artwork(slide, items[2]["image"], 8.2, top + h2 + .04, 5.133, h2, radius=0.0)
1275
+ # left caption band
1276
+ self.box(slide, 0, 6.02, 8.2, 1.0, "FBF6EE")
1277
+ self.box(slide, 0, 6.02, 8.2, .018, "B04A2A")
1278
+ self.text(slide, "01", .55, 6.12, .7, .38, 20, "accent", True)
1279
+ self.text(slide, items[0]["title"], 1.28, 6.12, 6.5, .34, 18, "text", True)
1280
+ self.text(slide, items[0]["body"], 1.28, 6.46, 6.5, .26, 11.5, "muted")
1281
+ # right top band
1282
+ self.box(slide, 8.2, 3.30, 5.133, .68, "FBF6EE")
1283
+ self.box(slide, 8.2, 3.30, 5.133, .016, "B04A2A")
1284
+ self.text(slide, "02", 8.42, 3.38, .55, .28, 13, "accent", True)
1285
+ self.text(slide, items[1]["title"], 9.02, 3.36, 4.0, .26, 12.5, "text", True)
1286
+ self.text(slide, items[1]["body"], 9.02, 3.60, 4.0, .22, 9.5, "muted")
1287
+ # right bottom band
1288
+ self.box(slide, 8.2, 6.34, 5.133, .68, "FBF6EE")
1289
+ self.box(slide, 8.2, 6.34, 5.133, .016, "B04A2A")
1290
+ self.text(slide, "03", 8.42, 6.42, .55, .28, 13, "accent", True)
1291
+ self.text(slide, items[2]["title"], 9.02, 6.40, 4.0, .26, 12.5, "text", True)
1292
+ self.text(slide, items[2]["body"], 9.02, 6.64, 4.0, .22, 9.5, "muted")
1293
+ elif layout == "state_triptych":
1294
+ items = page["items"]
1295
+ self.box(slide, 0, 0, 13.333, 1.32, "FBF6EE")
1296
+ self.text(slide, page["title"], .6, .36, 12.1, .55, 28, "text", True,
1297
+ font=self.head_font)
1298
+ if page.get("subtitle"):
1299
+ self.text(slide, page["subtitle"], .6, .94, 12.1, .28, 14, "muted")
1300
+ top = 1.32
1301
+ bottom = 7.02
1302
+ h = (bottom - top) / 2 - .02
1303
+ scrim = {"direction": "bottom", "color": "120B08", "start": .42,
1304
+ "end": .98, "opacity": .88}
1305
+ self.artwork(slide, items[0]["image"], 0, top, 8.2, bottom - top,
1306
+ radius=0.0, scrim=scrim)
1307
+ self.artwork(slide, items[1]["image"], 8.2, top, 5.133, h,
1308
+ radius=0.0, scrim=scrim)
1309
+ self.artwork(slide, items[2]["image"], 8.2, top + h + .04, 5.133, h,
1310
+ radius=0.0, scrim=scrim)
1311
+ self.text(slide, "01", .55, top + .28, .8, .44, 24, "FFFFFF", True)
1312
+ self.text(slide, items[0]["title"], .55, bottom - 1.00, 7.0, .38, 20, "FFFFFF", True)
1313
+ self.text(slide, items[0]["body"], .55, bottom - .58, 7.0, .24, 12, "FFF6EC")
1314
+ self.text(slide, "02", 8.65, top + .26, .8, .36, 20, "FFFFFF", True)
1315
+ self.text(slide, items[1]["title"], 8.65, top + h - .60, 4.3, .28, 14, "FFFFFF", True)
1316
+ self.text(slide, items[1]["body"], 8.65, top + h - .32, 4.3, .22, 10.5, "FFF6EC")
1317
+ y2 = top + h + .04
1318
+ self.text(slide, "03", 8.65, y2 + .26, .8, .36, 20, "FFFFFF", True)
1319
+ self.text(slide, items[2]["title"], 8.65, bottom - .66, 4.3, .28, 14, "FFFFFF", True)
1320
+ self.text(slide, items[2]["body"], 8.65, bottom - .38, 4.3, .22, 10.5, "FFF6EC")
1321
+ elif layout == "ingredient_board":
1322
+ items = page["items"]
1323
+ top = 2.02
1324
+ self.artwork(slide, page["image"], .6, top, 7.2, 4.86, radius=0.0)
1325
+ self.text(slide, page.get("fish_value", "800 g"), 8.0, 1.98, 2.25, .55,
1326
+ 30, "accent", True, font=self.head_font)
1327
+ self.text(slide, page.get("fish_label", "草鱼"), 8.0, 2.52, 2.2, .22,
1328
+ 10.5, "muted")
1329
+ self.text(slide, page.get("acid_value", "300 g"), 10.3, 1.98, 2.25, .55,
1330
+ 30, "accent", True, font=self.head_font)
1331
+ self.text(slide, page.get("acid_label", "酸菜"), 10.3, 2.52, 2.2, .22,
1332
+ 10.5, "muted")
1333
+ self.box(slide, 8.0, 2.82, 4.65, .012, "D8C4B2")
1334
+ for i, item in enumerate(items):
1335
+ y = 2.98 + i * .54
1336
+ self.text(slide, item["label"], 8.0, y, 1.0, .22, 9.5, "muted", True)
1337
+ self.text(slide, item["value"], 9.05, y - .03, 3.55, .42, 11.5, "text")
1338
+ if i < len(items) - 1:
1339
+ self.box(slide, 8.0, y + .46, 4.65, .008, "E7D8C8")
1340
+ if page.get("source"):
1341
+ self.text(slide, page["source"], 8.0, 6.25, 4.65, .55, 9.5, "muted")
1342
+ elif layout == "prep_stove":
1343
+ items = page["items"]
1344
+ total = float(page.get("total_minutes", 40))
1345
+ x0, x1 = 2.85, 12.55
1346
+ span = x1 - x0
1347
+ for tick in (0, 10, 20, 30, 40):
1348
+ x = x0 + span * tick / total
1349
+ self.box(slide, x, 2.16, .008, 3.45, "E6D5C5")
1350
+ self.text(slide, str(tick), x - .14, 1.92, .28, .18, 8, "muted",
1351
+ align=PP_ALIGN.CENTER)
1352
+ self.text(slide, "分钟", x1 + .02, 1.92, .4, .18, 8, "muted")
1353
+ for lane, y, label, hint, color in (
1354
+ ("prep", 2.42, "提前备料", "可提前做", "B98A63"),
1355
+ ("stove", 4.05, "灶前", "连续操作", "B04A2A")):
1356
+ entries = [item for item in items if item.get("lane") == lane]
1357
+ self.text(slide, label, .6, y - .04, 2.1, .30, 14, "text", True)
1358
+ self.text(slide, hint, .6, y + .28, 2.1, .20, 9.5, "muted")
1359
+ offset = 16.0 if lane == "stove" else 0.0
1360
+ cursor = 0.0
1361
+ for item in entries:
1362
+ minutes = float(item["minutes"])
1363
+ bx = x0 + span * (offset + cursor) / total
1364
+ bw = max(.55, span * minutes / total - .05)
1365
+ self.box(slide, bx + .025, y, bw, .92, color, rounded=True)
1366
+ title_size = 11 if bw > 1.15 else (9.5 if bw > .8 else 9)
1367
+ self.text(slide, item["title"], bx + .10, y + .08, bw - .16, .26,
1368
+ title_size, "FFFFFF", True)
1369
+ self.text(slide, f"{minutes:g} 分钟", bx + .10, y + .32, bw - .16,
1370
+ .20, 8.5, "FFF6EC")
1371
+ self.text(slide, item["body"], bx + .10, y + .52, bw - .16, .36,
1372
+ 8.2, "FFF6EC")
1373
+ cursor += minutes
1374
+ xh = x0 + span * 16 / total
1375
+ self.box(slide, xh, 2.16, .012, 3.45, "B04A2A")
1376
+ self.text(slide, "开火", xh - .18, 1.92, .5, .18, 8.5, "accent", True,
1377
+ align=PP_ALIGN.CENTER)
1378
+ self.text(slide, "16", .6, 5.62, .75, .55, 28, "accent", True)
1379
+ self.text(slide, "分钟 · 备料", 1.35, 5.76, 1.8, .22, 10, "muted")
1380
+ self.text(slide, "24", 3.4, 5.62, .75, .55, 28, "accent", True)
1381
+ self.text(slide, "分钟 · 灶前", 4.15, 5.76, 1.8, .22, 10, "muted")
1382
+ self.text(slide, "40", 6.2, 5.62, .75, .55, 28, "accent", True)
1383
+ self.text(slide, "分钟 · 上桌", 6.95, 5.76, 1.8, .22, 10, "muted")
1384
+ elif layout == "image_triptych":
1385
+ items = page["items"]
1386
+ top = 2.06
1387
+ self.artwork(slide, items[0]["image"], .6, top, 7.55, 4.38, radius=0.0)
1388
+ self.artwork(slide, items[1]["image"], 8.40, top, 4.30, 1.98, radius=0.0)
1389
+ self.artwork(slide, items[2]["image"], 8.40, 4.60, 4.30, 1.84, radius=0.0)
1390
+ self.box(slide, 8.24, top, .012, 4.38, "D8C6B4")
1391
+ self.text(slide, f"01 {items[0]['title']}", .6, 6.46, 7.55, .31, 17, "accent", True)
1392
+ self.text(slide, items[0]["body"], .6, 6.80, 7.55, .22, 12, "muted")
1393
+ self.text(slide, f"02 {items[1]['title']}", 8.40, 4.08, 4.30, .26, 14, "accent", True)
1394
+ self.text(slide, items[1]["body"], 8.40, 4.32, 4.30, .20, 11, "muted")
1395
+ self.text(slide, f"03 {items[2]['title']}", 8.40, 6.48, 4.30, .26, 14, "accent", True)
1396
+ self.text(slide, items[2]["body"], 8.40, 6.74, 4.30, .20, 11, "muted")
1397
+ elif layout in {"chart", "chart_dashboard"}:
1398
+ top = 2.05 if page.get("subtitle") else 1.65
1399
+ self.chart_page(slide, page, top=top, height=6.6 - top)
1400
+ elif layout == "table":
1401
+ headers, rows = page["headers"], page["rows"]
1402
+ table = slide.shapes.add_table(len(rows) + 1, len(headers), Inches(.6), Inches(2.1),
1403
+ Inches(12.1), Inches(4.4)).table
1404
+ weights = [max([len(str(headers[index]))] +
1405
+ [len(str(row[index])) for row in rows])
1406
+ for index in range(len(headers))]
1407
+ total = sum(weights) or 1
1408
+ for index, weight in enumerate(weights):
1409
+ table.columns[index].width = Inches(12.1 * weight / total)
1410
+ for row_index, row in enumerate([headers] + rows):
1411
+ for column_index, value in enumerate(row):
1412
+ cell = table.cell(row_index, column_index)
1413
+ cell.fill.solid()
1414
+ cell.fill.fore_color.rgb = RGBColor.from_string(self.colors["accent" if row_index == 0 else "panel"])
1415
+ cell.text = str(value)
1416
+ for paragraph in cell.text_frame.paragraphs:
1417
+ paragraph.font.name = self.font
1418
+ paragraph.font.size = Pt(18)
1419
+ paragraph.font.bold = row_index == 0
1420
+ paragraph.font.color.rgb = RGBColor.from_string(self.colors["bg" if row_index == 0 else "text"])
1421
+ for run in paragraph.runs:
1422
+ run._r.get_or_add_rPr().append(run._r.makeelement(qn("a:ea"), {"typeface": self.font}))
1423
+ if page.get("source") and layout not in {"glass_overlay", "kpi_showcase",
1424
+ "image_annotated", "ingredient_board",
1425
+ "ingredient_split"}:
1426
+ self.text(slide, page["source"], .6, 6.7, 12.1, .27, 11, "muted")
1427
+
1428
+ def chart_page(self, slide, page, top=1.5, height=5.15):
1429
+ """Compose one chart or 2–4 coordinated charts with native annotations."""
1430
+ validate_chart_page(page, self.base)
1431
+ charts = [page["chart"]] if page["layout"] == "chart" else page["charts"]
1432
+ count = len(charts)
1433
+ columns = 1 if count == 1 else 2
1434
+ rows = math.ceil(count / columns)
1435
+ panel_w = (12.1 - .25 * (columns - 1)) / columns
1436
+ has_summary = bool(page.get("takeaway"))
1437
+ available_h = height - (.55 if has_summary else 0)
1438
+ panel_h = (available_h - .2 * (rows - 1)) / rows
1439
+ for index, spec in enumerate(charts):
1440
+ x = .6 + (index % columns) * (panel_w + .25)
1441
+ y = top + (index // columns) * (panel_h + .2)
1442
+ # A third chart spans the bottom row rather than leaving a blank cell.
1443
+ width = 12.1 if count == 3 and index == 2 else panel_w
1444
+ self.box(slide, x, y, width, panel_h)
1445
+ heading = spec.get("title", "")
1446
+ if spec.get("unit"):
1447
+ heading += (" · " if heading else "") + spec["unit"]
1448
+ heading_h = .36 if heading else 0
1449
+ if heading:
1450
+ self.text(slide, heading, x + .2, y + .12, width - .4,
1451
+ heading_h, 13 if rows > 1 else 17, "text", True)
1452
+ tail = spec.get("takeaway", "")
1453
+ source = spec.get("source", "")
1454
+ tail_h = (.33 if tail else 0) + (.24 if source else 0)
1455
+ chart_y = y + .12 + heading_h
1456
+ chart_h = panel_h - .24 - heading_h - tail_h
1457
+ draw_chart(slide, spec, self.base, (x + .12, chart_y, width - .24, chart_h),
1458
+ self.font, self.colors)
1459
+ cursor = chart_y + chart_h
1460
+ if tail:
1461
+ self.text(slide, tail, x + .2, cursor, width - .4, .33,
1462
+ 11 if rows > 1 else 13, "accent", True)
1463
+ cursor += .33
1464
+ if source:
1465
+ self.text(slide, source, x + .2, cursor, width - .4, .24, 9, "muted")
1466
+ if has_summary:
1467
+ self.text(slide, page["takeaway"], .8, top + height - .4,
1468
+ 11.7, .4, 16, "accent", True)
1469
+
1470
+ # The probation v2 deck uses native PowerPoint shapes for its explanatory
1471
+ # pages. These primitives deliberately keep the template's restrained
1472
+ # paper palette while giving each page a different composition.
1473
+ def _probation_bg(self, slide):
1474
+ slide.background.fill.solid()
1475
+ slide.background.fill.fore_color.rgb = RGBColor.from_string("F4F7FB")
1476
+
1477
+ def _probation_header(self, slide, title, subtitle, index):
1478
+ self._probation_bg(slide)
1479
+ self.text(slide, title, .62, .36, 10.8, .45, 25, "text", True)
1480
+ self.box(slide, .62, 1.0, 1.15, .06, "accent")
1481
+ if subtitle:
1482
+ self.text(slide, subtitle, 1.92, .85, 10.7, .28, 10.5, "muted")
1483
+ self.text(slide, "人形机器人(上海)有限公司 | 轮臂-应用开发 | 赵文辉",
1484
+ .55, 7.12, 10.5, .18, 8.5, "muted")
1485
+ self.text(slide, f"{index:02d}", 12.25, 7.08, .45, .22, 9, "muted")
1486
+
1487
+ def _probation_panel(self, slide, x, y, w, h, rounded=True):
1488
+ return self.box(slide, x, y, w, h, "panel", rounded=rounded)
1489
+
1490
+ def _probation_image(self, slide, filename, x, y, w, h, used_assets, crop=None):
1491
+ path = (self.base / filename).resolve()
1492
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
1493
+ if digest in used_assets:
1494
+ raise ValueError(f"probation deck reuses image asset: {path}")
1495
+ used_assets.add(digest)
1496
+ self._probation_panel(slide, x, y, w, h, rounded=True)
1497
+ if path.suffix.lower() == ".gif" and crop is None:
1498
+ self._direct_picture(slide, path, x + .12, y + .12, w - .24, h - .24)
1499
+ else:
1500
+ self.picture(slide, filename, x + .12, y + .12, w - .24, h - .24, crop=crop)
1501
+
1502
+ def _direct_picture(self, slide, path, x, y, w, h):
1503
+ """Insert the original media bytes so animated GIFs remain animated."""
1504
+ with Image.open(path) as source:
1505
+ width, height = source.size
1506
+ ratio = min(w / width, h / height)
1507
+ display_w, display_h = width * ratio, height * ratio
1508
+ left, top = x + (w - display_w) / 2, y + (h - display_h) / 2
1509
+ slide.shapes.add_picture(str(path), Inches(left), Inches(top),
1510
+ Inches(display_w), Inches(display_h))
1511
+
1512
+ def _probation_arrow(self, slide, x, y, w=.28, h=.38):
1513
+ shape = slide.shapes.add_shape(MSO_SHAPE.CHEVRON, Inches(x), Inches(y), Inches(w), Inches(h))
1514
+ shape.fill.solid()
1515
+ shape.fill.fore_color.rgb = RGBColor.from_string(self.colors["accent"])
1516
+ shape.line.fill.background()
1517
+
1518
+ def _layered_photo(self, slide, item, x, y, w, h, used_assets):
1519
+ """Place a photo and aligned RGBA layers as separate editable pictures."""
1520
+ image_path = self.base / item["image"]
1521
+ digest = hashlib.sha256(image_path.read_bytes()).hexdigest()
1522
+ if digest in used_assets:
1523
+ raise ValueError(f"probation deck reuses image asset: {image_path}")
1524
+ used_assets.add(digest)
1525
+ with Image.open(image_path) as source:
1526
+ photo = ImageOps.exif_transpose(source).convert("RGB")
1527
+ source_size = photo.size
1528
+ crop = item["crop"]
1529
+ if (len(crop) != 4 or any(type(v) is not int for v in crop)
1530
+ or not 0 <= crop[0] < crop[2] <= photo.width
1531
+ or not 0 <= crop[1] < crop[3] <= photo.height):
1532
+ raise ValueError("invalid layered photo crop")
1533
+ photo = photo.crop(tuple(crop))
1534
+ left, top, ratio = self._picture_image(slide, photo, x, y, w, h)
1535
+ for filename in item["layers"]:
1536
+ with Image.open(self.base / filename) as source:
1537
+ if source.size != source_size or source.mode != "RGBA":
1538
+ raise ValueError("overlay must be an RGBA layer matching the source photo dimensions")
1539
+ layer = source.crop(tuple(crop))
1540
+ payload = io.BytesIO()
1541
+ layer.save(payload, "PNG")
1542
+ payload.seek(0)
1543
+ slide.shapes.add_picture(payload, Inches(left), Inches(top),
1544
+ Inches(layer.width * ratio), Inches(layer.height * ratio))
1545
+
1546
+ def _probation_tag(self, slide, value, x, y, w, color="accent"):
1547
+ self.box(slide, x, y, w, .28, color, rounded=True)
1548
+ self.text(slide, value, x + .08, y + .035, w - .16, .18, 9.5,
1549
+ "bg" if color == "accent" else "text", True)
1550
+
1551
+ def _pose_photo(self, slide, frame, projections, x, y, w, h):
1552
+ """Draw recorded candidates and selected poses using one native style."""
1553
+ crop = frame["crop"]
1554
+ with Image.open(self.base / frame["image"]) as source:
1555
+ original = ImageOps.exif_transpose(source).convert("RGB")
1556
+ if not (0 <= crop[0] < crop[2] <= original.width
1557
+ and 0 <= crop[1] < crop[3] <= original.height):
1558
+ raise ValueError("gripper photo crop exceeds source dimensions")
1559
+ picture = original.crop(tuple(crop))
1560
+ # A full candidate set can contain dozens of poses. Rasterize only
1561
+ # that dense overlay in memory so the deck remains responsive while
1562
+ # preserving every candidate and the same geometry/colors.
1563
+ if len(projections) > 8:
1564
+ composite = picture.convert("RGBA")
1565
+ draw = ImageDraw.Draw(composite)
1566
+ for projection in projections:
1567
+ color = (23, 108, 136, 235) if projection.get("status") != "rejected" else (192, 57, 43, 235)
1568
+ for start, end in projection["segments"]:
1569
+ points = [(round(start[0] - crop[0]), round(start[1] - crop[1])),
1570
+ (round(end[0] - crop[0]), round(end[1] - crop[1]))]
1571
+ draw.line(points, fill=color, width=max(2, round(picture.width / 180)))
1572
+ cx, cy = projection["center_uv"]
1573
+ radius = max(2, round(picture.width / 180))
1574
+ draw.ellipse((round(cx - crop[0] - radius), round(cy - crop[1] - radius),
1575
+ round(cx - crop[0] + radius), round(cy - crop[1] + radius)), fill=color)
1576
+ self._picture_image(slide, composite.convert("RGB"), x, y, w, h)
1577
+ return
1578
+ left, top, ratio = self._picture_image(slide, picture, x, y, w, h)
1579
+ def to_slide(point):
1580
+ u, v = point
1581
+ if not crop[0] <= u <= crop[2] or not crop[1] <= v <= crop[3]:
1582
+ raise ValueError("gripper projection lies outside comparison crop")
1583
+ return left + (u - crop[0]) * ratio, top + (v - crop[1]) * ratio
1584
+ for projection in projections:
1585
+ color = self.colors["accent"] if projection.get("status") != "rejected" else self.colors["danger"]
1586
+ for start, end in projection["segments"]:
1587
+ sx, sy = to_slide(start)
1588
+ ex, ey = to_slide(end)
1589
+ connector = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
1590
+ Inches(sx), Inches(sy), Inches(ex), Inches(ey))
1591
+ connector.line.color.rgb = RGBColor.from_string(color)
1592
+ connector.line.width = Pt(1.25)
1593
+ cx, cy = to_slide(projection["center_uv"])
1594
+ center = slide.shapes.add_shape(MSO_SHAPE.OVAL,
1595
+ Inches(cx - .027), Inches(cy - .027), Inches(.054), Inches(.054))
1596
+ center.fill.solid()
1597
+ center.fill.fore_color.rgb = RGBColor.from_string(color)
1598
+ center.line.fill.background()
1599
+
1600
+ def _grasp_panel(self, slide, frame, phase, x, y, w, h):
1601
+ projection = gripper_projection(self.base / frame[phase], self.base / frame["calibration"])
1602
+ expected = frame.get(f"{phase}_opening_mm")
1603
+ if expected is not None and abs(projection["opening_mm"] - expected) > .02:
1604
+ raise ValueError("recorded gripper opening does not match comparison statistics")
1605
+ self._pose_photo(slide, frame, [projection], x, y, w, h)
1606
+ self.text(slide, f"所选开合 {projection['opening_mm']:.2f} mm", x, y + h + .08,
1607
+ w, .26, 13, "text", True)
1608
+
1609
+ def _probation_card(self, slide, title, body, x, y, w, h, number=None,
1610
+ title_color="accent", body_size=15):
1611
+ self._probation_panel(slide, x, y, w, h, rounded=True)
1612
+ if h < 1.0:
1613
+ self.text(slide, title, x + .22, y + .18, 2.35, .35, 16, title_color, True)
1614
+ self.text(slide, body, x + 2.8, y + .18, w - 3.05, h - .3, body_size)
1615
+ return
1616
+ if number is not None and w < 3.5:
1617
+ self.text(slide, f"{number:02d}", x + .22, y + .15, .55, .38, 21, "accent", True)
1618
+ self.text(slide, title, x + .22, y + .68, w - .44, .42, 17, title_color, True)
1619
+ self.text(slide, body, x + .22, y + 1.28, w - .44, h - 1.45, body_size)
1620
+ return
1621
+ if number is not None:
1622
+ self.text(slide, f"{number:02d}", x + .22, y + .18, .55, .38, 21,
1623
+ "accent", True)
1624
+ title_x, title_y = x + .92, y + .2
1625
+ title_w = w - 1.15
1626
+ else:
1627
+ title_x, title_y = x + .22, y + .2
1628
+ title_w = w - .44
1629
+ self.text(slide, title, title_x, title_y, title_w, .42, 18,
1630
+ title_color, True)
1631
+ self.text(slide, body, x + .22, y + .78, w - .44, h - .96,
1632
+ body_size, "text")
1633
+
1634
+ def compose_probation_v2(self, slide, page, index, used_assets):
1635
+ """Compose a native, varied page for the v2 probation presentation."""
1636
+ layout = page["layout"]
1637
+ if layout == "thanks":
1638
+ # Match the original cover's composition at the end: artwork on
1639
+ # the right and a quiet, deliberately typeset thank-you on the left.
1640
+ self.text(slide, page["headline"], .78, 2.15, 6.2, .85, 40, "accent", True)
1641
+ self.box(slide, .82, 3.25, 1.1, .055, "accent")
1642
+ self.text(slide, page["body"], .82, 3.72, 6.2, 1.2, 20, "text")
1643
+ self.text(slide, page["footer"], .82, 5.8, 6.2, .45, 17, "muted")
1644
+ return
1645
+ self._probation_header(slide, page["title"], page.get("subtitle", ""), index)
1646
+
1647
+ if layout in {"chart", "chart_dashboard"}:
1648
+ self.chart_page(slide, page)
1649
+ elif layout == "section_divider":
1650
+ # Section pages reuse the directory's restrained typography but
1651
+ # give the audience a clear reset before a dense block of work.
1652
+ self.text(slide, "SECTION", .84, 1.78, 2.0, .32, 14, "muted", True)
1653
+ self.text(slide, page.get("section_no", "01"), .82, 2.23, 1.15, .92,
1654
+ 46, "accent", True)
1655
+ self.box(slide, 2.28, 2.2, .08, 1.35, "accent")
1656
+ self.text(slide, page["section_title"], 2.7, 2.23, 7.6, .58,
1657
+ 31, "text", True)
1658
+ self.text(slide, page.get("section_subtitle", ""), 2.72, 2.92,
1659
+ 8.8, .4, 17, "muted")
1660
+ self.box(slide, 2.72, 3.62, 9.55, .012, "muted")
1661
+ for i, item in enumerate(page.get("items", [])):
1662
+ y = 4.0 + i * .58
1663
+ self.text(slide, f"{i + 1:02d}", 2.72, y, .55, .27,
1664
+ 13, "accent", True)
1665
+ self.text(slide, item, 3.5, y, 8.4, .3, 16, "text")
1666
+ self.text(slide, page.get("transition", "下面进入本节内容"),
1667
+ 2.72, 6.35, 4.5, .28, 13, "muted")
1668
+ elif layout == "directory":
1669
+ entries = page["entries"]
1670
+ self.text(slide, "CONTENTS", .82, 1.7, 2.4, .45, 22, "muted", True)
1671
+ for i, item in enumerate(entries):
1672
+ y = 1.72 + i * 1.12
1673
+ self.text(slide, f"{i + 1:02d}", 4.25, y, .8, .6, 30, "accent", True)
1674
+ self.text(slide, item["title"], 5.35, y, 6.7, .46, 23, "text", True)
1675
+ self.text(slide, item["body"], 5.35, y + .54, 6.7, .3, 14, "muted")
1676
+ if i < 3:
1677
+ self.box(slide, 4.25, y + .98, 8.05, .01, "muted")
1678
+ elif layout == "overview":
1679
+ items = page["items"]
1680
+ self.text(slide, "工作方向", .9, 1.7, 2.5, .36, 17, "muted", True)
1681
+ self.text(slide, "主要负责内容", 4.05, 1.7, 7.6, .36, 17, "muted", True)
1682
+ for i, item in enumerate(items):
1683
+ y = 2.27 + i * 1.35
1684
+ self.box(slide, .8, y, 2.65, 1.1, "accent")
1685
+ self.text(slide, item["title"], 1.06, y + .32, 2.1, .43, 22, "bg", True)
1686
+ self.text(slide, item["body"].replace("\n", ";"), 4.05, y + .21, 8.0, .76, 18)
1687
+ self.box(slide, 4.05, y + 1.13, 8.15, .012, "muted")
1688
+ elif layout == "timeline":
1689
+ self.box(slide, .95, 3.35, 11.35, .06, "accent")
1690
+ for i, item in enumerate(page["items"]):
1691
+ x = 1.0 + i * 3.9
1692
+ self.box(slide, x, 3.18, .38, .38, "accent", rounded=True)
1693
+ self._probation_card(slide, item["title"], item["body"], x - .32, 1.62,
1694
+ 3.35, 1.35, body_size=13)
1695
+ self._probation_card(slide, item["detail_title"], item["detail"], x - .32,
1696
+ 3.72, 3.35, 1.72, body_size=13)
1697
+ elif layout == "pipeline":
1698
+ items = page["items"]
1699
+ width = 2.2
1700
+ for i, item in enumerate(items):
1701
+ x = .55 + i * 2.55
1702
+ self._probation_card(slide, item["title"], item["body"], x, 1.7, width, 2.2,
1703
+ i + 1, body_size=12.5)
1704
+ if i < len(items) - 1:
1705
+ self._probation_arrow(slide, x + width + .08, 2.58, .25, .36)
1706
+ self._probation_panel(slide, .72, 4.48, 11.88, 1.65)
1707
+ self.text(slide, page["bottom_title"], .98, 4.76, 2.0, .32, 17, "accent", True)
1708
+ self.text(slide, page["bottom"], 3.0, 4.7, 9.1, .72, 16, "text")
1709
+ elif layout == "root_cause":
1710
+ images = page["images"]
1711
+ self._probation_image(slide, images[0]["path"], .72, 1.65, 2.85, 2.2, used_assets,
1712
+ crop=images[0].get("crop"))
1713
+ self.text(slide, images[0]["label"], .82, 3.92, 2.65, .3, 12, "muted", True)
1714
+ self._probation_image(slide, images[1]["path"], .72, 4.35, 2.85, 1.85, used_assets,
1715
+ crop=images[1].get("crop"))
1716
+ self.text(slide, images[1]["label"], .82, 6.28, 2.65, .3, 12, "muted", True)
1717
+ self.text(slide, page["result"], 3.95, 1.68, 8.45, .68, 21, "accent", True)
1718
+ for i, item in enumerate(page["items"]):
1719
+ self._probation_card(slide, item["title"], item["body"], 3.95,
1720
+ 2.55 + i * 1.18, 8.45, .93, i + 1, body_size=12.5)
1721
+ elif layout == "depth_pair":
1722
+ images = depth_pair(self.base / page["before_array"], self.base / page["after_array"],
1723
+ page["crop"], page["limits_m"])
1724
+ for i, image in enumerate(images):
1725
+ x = .85 + i * 6.1
1726
+ self.text(slide, page["labels"][i], x, 1.65, 5.4, .4, 21, "accent", True)
1727
+ self._picture_image(slide, image, x, 2.18, 5.4, 2.75)
1728
+ # Native legend uses the identical palette/range as both images.
1729
+ for i, rgb in enumerate(depth_colors([i / 59 for i in range(60)])):
1730
+ shape = self.box(slide, 3.7 + i * .1, 5.25, .102, .18)
1731
+ shape.fill.fore_color.rgb = RGBColor(*[int(v) for v in rgb])
1732
+ self.text(slide, f"{page['limits_m'][0]:.2f} m", 2.8, 5.2, .85, .3, 13, "muted")
1733
+ self.text(slide, f"{page['limits_m'][1]:.2f} m", 9.8, 5.2, .9, .3, 13, "muted")
1734
+ self.text(slide, page["note"], .85, 5.85, 11.6, .9, 17)
1735
+ elif layout == "depth_case":
1736
+ digest = hashlib.sha256((self.base / page["image"]).read_bytes()).hexdigest()
1737
+ if digest in used_assets:
1738
+ raise ValueError("depth case photo is reused on another slide")
1739
+ used_assets.add(digest)
1740
+ depth_images = depth_pair(self.base / page["before_array"],
1741
+ self.base / page["after_array"], page["depth_crop"], page["limits_m"])
1742
+ for i, phase in enumerate(("before", "after")):
1743
+ x = .72 if i == 0 else 8.13
1744
+ result = candidate_projections(self.base / page[f"{phase}_response"],
1745
+ self.base / page["calibration"], page.get("display_count", 3))
1746
+ if result["total"] != page[f"{phase}_count"]:
1747
+ raise ValueError("depth case candidate count does not match source")
1748
+ self.text(slide, "优化前" if i == 0 else "优化后", x, 1.6, 4.48, .4,
1749
+ 21, "accent", True)
1750
+ self._pose_photo(slide, page, result["projections"], x, 2.18, 4.48, 2.25)
1751
+ self.text(slide, "原始深度" if i == 0 else "补全深度", x, 4.63, 2.3, .25,
1752
+ 12, "muted")
1753
+ self._picture_image(slide, depth_images[i], x, 5.0, 2.3, 1.05)
1754
+ self.text(slide, f"{result['total']} 个候选\n{page[f'{phase}_points']} 个目标点",
1755
+ x + 2.55, 4.98, 1.93, 1.1, 16, "text", True)
1756
+ self.text(slide, "优化方案", 5.44, 1.6, 2.38, .4, 21, "accent", True)
1757
+ self.text(slide, page["solution"], 5.44, 2.4, 2.38, 2.6, 17)
1758
+ for i, rgb in enumerate(depth_colors([i / 59 for i in range(60)])):
1759
+ shape = self.box(slide, 5.44 + i * .039, 5.36, .04, .14)
1760
+ shape.fill.fore_color.rgb = RGBColor(*[int(v) for v in rgb])
1761
+ self.text(slide, f"{page['limits_m'][0]:.2f}–{page['limits_m'][1]:.2f} m",
1762
+ 5.44, 5.68, 2.38, .25, 12, "muted")
1763
+ self.text(slide, page["note"], .82, 6.5, 11.6, .32, 12, "muted")
1764
+ elif layout == "pose_case":
1765
+ if len(page["frames"]) != 3:
1766
+ raise ValueError("pose case requires three paired rounds")
1767
+ for label, x, w in [("优化前", .72, 4.78), ("优化方案", 5.62, 1.95),
1768
+ ("优化后", 7.72, 4.78)]:
1769
+ self.text(slide, label, x, 1.58, w, .4, 21, "accent", True)
1770
+ # The middle column is intentionally narrow; keep the root-cause
1771
+ # wording readable without forcing it into a separate slide.
1772
+ self.text(slide, page["solution"], 5.62, 2.48, 1.95, 3.18, 13.5)
1773
+ for i, frame in enumerate(page["frames"]):
1774
+ digest = hashlib.sha256((self.base / frame["image"]).read_bytes()).hexdigest()
1775
+ if digest in used_assets:
1776
+ raise ValueError("comparison frame is reused on another slide")
1777
+ used_assets.add(digest)
1778
+ y = 2.18 + i * 1.36
1779
+ for phase, x in (("before", .72), ("after", 7.72)):
1780
+ gate = gated_candidate_projections(self.base / frame[phase],
1781
+ self.base / frame["calibration"])
1782
+ projection = gripper_projection(self.base / frame[phase],
1783
+ self.base / frame["calibration"])
1784
+ if abs(projection["opening_mm"] - frame[f"{phase}_opening_mm"]) > .02:
1785
+ raise ValueError("recorded gripper opening does not match comparison statistics")
1786
+ self._pose_photo(slide, frame, gate["projections"], x, y, 2.15, 1.08)
1787
+ self.text(slide,
1788
+ f"第 {i+1} 轮 · {projection['opening_mm']:.2f} mm\n"
1789
+ f"蓝 {gate['accepted']} · 红 {gate['rejected']}",
1790
+ x + 2.36, y + .14, 2.42, .81, 14)
1791
+ self.text(slide, page["note"], .82, 6.55, 11.6, .3, 12, "muted")
1792
+ elif layout == "efficiency_case":
1793
+ for label, x in (("优化前", .82), ("优化方案", 4.98), ("优化后", 9.02)):
1794
+ self.text(slide, label, x, 1.7, 3.5, .4, 21, "accent", True)
1795
+ self.text(slide, page["before"], .82, 2.48, 3.3, .62, 32, "text", True)
1796
+ self.text(slide, page["solution"], 4.98, 2.42, 3.5, 1.45, 17)
1797
+ self.text(slide, page["after"], 9.02, 2.48, 3.3, .62, 32, "accent", True)
1798
+ self.text(slide, page["delta"], 9.02, 3.22, 3.3, .4, 17, "accent")
1799
+ self.text(slide, "优化后耗时构成", .82, 3.72, 3.6, .4, 21, "text", True)
1800
+ self.text(slide, page["after_detail"], 4.98, 3.68, 7.4, .62, 14)
1801
+ self.text(slide, "夹爪几何检查", .82, 4.34, 3.6, .4, 21, "text", True)
1802
+ self.text(slide, page["check_solution"], 4.98, 4.34, 7.4, .65, 14)
1803
+ table = slide.shapes.add_table(len(page["rows"]) + 1, 4, Inches(.82),
1804
+ Inches(5.08), Inches(11.7), Inches(1.0)).table
1805
+ for ri, row in enumerate([page["headers"]] + page["rows"]):
1806
+ for ci, value in enumerate(row):
1807
+ cell = table.cell(ri, ci)
1808
+ cell.text = str(value)
1809
+ cell.fill.solid()
1810
+ cell.fill.fore_color.rgb = RGBColor.from_string(self.colors["accent" if ri == 0 else "panel"])
1811
+ for paragraph in cell.text_frame.paragraphs:
1812
+ paragraph.font.name = self.font
1813
+ paragraph.font.size = Pt(16)
1814
+ paragraph.font.color.rgb = RGBColor.from_string(self.colors["bg" if ri == 0 else "text"])
1815
+ for run in paragraph.runs:
1816
+ run._r.get_or_add_rPr().append(run._r.makeelement(qn("a:ea"), {"typeface": self.font}))
1817
+ self.text(slide, page["note"], .82, 6.58, 11.6, .3, 12, "muted")
1818
+ elif layout == "grasp_comparison":
1819
+ frames = page["frames"]
1820
+ if len(frames) != 3:
1821
+ raise ValueError("grasp comparison requires three paired rounds")
1822
+ self.text(slide, "优化前", .55, 2.55, .9, .65, 18, "muted", True)
1823
+ self.text(slide, "优化后", .55, 4.82, .9, .65, 18, "accent", True)
1824
+ for i, frame in enumerate(frames):
1825
+ digest = hashlib.sha256((self.base / frame["image"]).read_bytes()).hexdigest()
1826
+ if digest in used_assets:
1827
+ raise ValueError("comparison frame is reused on another slide")
1828
+ used_assets.add(digest)
1829
+ x = 1.55 + i * 3.72
1830
+ self.text(slide, frame["label"], x, 1.63, 3.4, .35, 17, "accent", True)
1831
+ self._grasp_panel(slide, frame, "before", x, 2.08, 3.45, 1.64)
1832
+ self._grasp_panel(slide, frame, "after", x, 4.35, 3.45, 1.64)
1833
+ self.text(slide, page["note"], .82, 6.54, 11.6, .3, 12, "muted")
1834
+ elif layout == "evidence":
1835
+ has_image = bool(page.get("image"))
1836
+ if has_image:
1837
+ self._probation_image(slide, page["image"], .7, 1.72, 3.8, 4.65, used_assets,
1838
+ crop=page.get("image_crop"))
1839
+ for i, metric in enumerate(page["charts"]):
1840
+ x, y, w, h = ((4.85, 1.6 + i * 2.1, 7.4, 1.55) if has_image
1841
+ else (.82 + i * 6.12, 1.82, 5.55, 3.65))
1842
+ self.text(slide, metric["title"], x, y, w, .4, 19, "accent", True)
1843
+ data = CategoryChartData()
1844
+ data.categories = ["第 1 轮", "第 2 轮", "第 3 轮"]
1845
+ data.add_series("优化前", metric["before"])
1846
+ data.add_series("优化后", metric["after"])
1847
+ chart = slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED,
1848
+ Inches(x), Inches(y + .55), Inches(w), Inches(h), data).chart
1849
+ chart.has_legend = True
1850
+ chart.legend.font.size = Pt(10)
1851
+ chart.legend.font.name = self.font
1852
+ chart.legend.include_in_layout = False
1853
+ chart.plots[0].has_data_labels = True
1854
+ chart.plots[0].data_labels.font.size = Pt(11)
1855
+ chart.plots[0].data_labels.font.name = self.font
1856
+ for axis in (chart.category_axis, chart.value_axis):
1857
+ axis.tick_labels.font.size = Pt(10)
1858
+ axis.tick_labels.font.name = self.font
1859
+ for j, series in enumerate(chart.series):
1860
+ series.format.fill.solid()
1861
+ series.format.fill.fore_color.rgb = RGBColor.from_string("D97706" if j == 0 else "176C88")
1862
+ note_x, note_y, note_w = (4.95, 5.9, 7.25) if has_image else (.82, 6.2, 11.7)
1863
+ self.text(slide, page["note"], note_x, note_y, note_w, .42, 13, "text")
1864
+ elif layout == "performance":
1865
+ self.text(slide, "同一 1280×720 场景的完整感知调用", .82, 1.72, 6.2, .32, 15, "muted")
1866
+ self._probation_panel(slide, .82, 2.2, 11.7, 1.52)
1867
+ self.text(slide, page["before"], 1.22, 2.62, 2.7, .55, 31, "muted", True)
1868
+ self._probation_arrow(slide, 4.15, 2.68, .5, .5)
1869
+ self.text(slide, page["after"], 5.05, 2.62, 2.7, .55, 31, "accent", True)
1870
+ self._probation_tag(slide, page["delta"], 8.6, 2.78, 2.5)
1871
+ self.text(slide, page["caption"], 1.22, 3.35, 10.3, .25, 12, "muted")
1872
+ self._probation_card(slide, "为什么变快", page["reason"], .82, 4.22, 5.55, 1.55, 1, body_size=14)
1873
+ self._probation_card(slide, "工程侧同步", page["engineering"], 6.95, 4.22, 5.55, 1.55, 2, body_size=14)
1874
+ elif layout == "field":
1875
+ images = page["images"]
1876
+ if len(images) == 1:
1877
+ item = images[0]
1878
+ self._probation_image(slide, item["path"], .72, 1.7, 7.15, 4.2, used_assets)
1879
+ self.text(slide, item["label"], 8.2, 2.05, 4.25, .55, 20, "accent", True)
1880
+ self.text(slide, item["caption"], 8.2, 2.85, 4.25, 1.25, 17, "text")
1881
+ self._probation_panel(slide, 8.2, 4.55, 4.25, 1.15, rounded=False)
1882
+ self.text(slide, page["flow"], 8.45, 4.82, 3.75, .78, 14, "text", True)
1883
+ else:
1884
+ for i, item in enumerate(images):
1885
+ x = .72 + i * 6.15
1886
+ self._probation_image(slide, item["path"], x, 1.65, 5.65, 3.45, used_assets)
1887
+ self.text(slide, item["label"], x + .12, 5.22, 5.4, .3, 14, "accent", True)
1888
+ self.text(slide, item["caption"], x + .12, 5.57, 5.4, .36, 12, "muted")
1889
+ self.text(slide, page["flow"], .82, 6.35, 11.8, .35, 17, "text", True)
1890
+ elif layout == "field_story":
1891
+ for i, item in enumerate(page["images"]):
1892
+ x = .72 + i * 4.17
1893
+ self._probation_image(slide, item["path"], x, 1.82, 3.75, 2.4, used_assets)
1894
+ self.text(slide, item["label"], x, 4.5, 3.75, .4, 20, "accent", True)
1895
+ self.text(slide, item["caption"], x, 5.12, 3.75, 1.05, 16)
1896
+ self.text(slide, page["note"], .82, 6.55, 11.6, .3, 12, "muted")
1897
+ elif layout == "visual_gallery":
1898
+ if len(page["items"]) != 3:
1899
+ raise ValueError("visual gallery requires three source captures")
1900
+ for i, item in enumerate(page["items"]):
1901
+ x = .72 + i * 4.17
1902
+ self.text(slide, item["title"], x, 1.65, 3.75, .4, 20, "accent", True)
1903
+ self._layered_photo(slide, item, x, 2.25, 3.75, 2.8, used_assets)
1904
+ self.text(slide, item["body"], x, 5.32, 3.75, .95, 15)
1905
+ self.text(slide, page["note"], .82, 6.55, 11.6, .3, 12, "muted")
1906
+ elif layout == "native_gallery":
1907
+ if len(page["items"]) != 3:
1908
+ raise ValueError("native gallery requires three source captures")
1909
+ for i, item in enumerate(page["items"]):
1910
+ x = .72 + i * 4.17
1911
+ digest = hashlib.sha256((self.base / item["image"]).read_bytes()).hexdigest()
1912
+ if digest in used_assets:
1913
+ raise ValueError("native gallery photo is reused on another slide")
1914
+ used_assets.add(digest)
1915
+ projection = gripper_projection(self.base / item["pose"],
1916
+ self.base / item["calibration"])
1917
+ self.text(slide, item["title"], x, 1.65, 3.75, .4, 20, "accent", True)
1918
+ self._pose_photo(slide, item, [projection], x, 2.25, 3.75, 2.8)
1919
+ self.text(slide, item["body"], x, 5.32, 3.75, .95, 15)
1920
+ self.text(slide, page["note"], .82, 6.55, 11.6, .3, 12, "muted")
1921
+ elif layout == "data_table":
1922
+ self.compose(slide, {"layout": "table", "headers": page["headers"],
1923
+ "rows": page["rows"], "source": page["note"]})
1924
+ elif layout == "wrc":
1925
+ self._probation_image(slide, page["image"], .72, 1.65, 4.2, 4.75, used_assets)
1926
+ for i, item in enumerate(page["items"]):
1927
+ self._probation_card(slide, item["title"], item["body"], 5.35, 1.65 + i * 1.52,
1928
+ 6.95, 1.2, i + 1, body_size=13)
1929
+ elif layout == "voice":
1930
+ width = 2.78
1931
+ for i, item in enumerate(page["items"]):
1932
+ x = .58 + i * 3.18
1933
+ self._probation_card(slide, item["title"], item["body"], x, 2.0, width, 2.1,
1934
+ i + 1, body_size=12.5)
1935
+ if i < len(page["items"]) - 1:
1936
+ self._probation_arrow(slide, x + width + .1, 2.78, .22, .34)
1937
+ if page.get("bottom"):
1938
+ self._probation_panel(slide, .82, 4.72, 11.7, 1.18)
1939
+ self.text(slide, page["bottom"], 1.1, 5.05, 11.1, .42, 17, "accent", True)
1940
+ elif layout == "engineering":
1941
+ for i, item in enumerate(page["items"]):
1942
+ y = 1.7 + i * 1.02
1943
+ self.text(slide, f"{i+1:02d}", .9, y + .13, .7, .46, 23, "accent", True)
1944
+ self.text(slide, item["title"], 1.9, y + .13, 2.35, .4, 20, "text", True)
1945
+ self.text(slide, item["body"], 5.0, y + .14, 7.15, .58, 17)
1946
+ self.box(slide, .9, y + .86, 11.35, .013, "muted")
1947
+ self.text(slide, page["bottom"], .86, 6.35, 11.6, .45, 13, "muted")
1948
+ elif layout == "contribution":
1949
+ self.text(slide, page["lead"], .84, 1.48, 11.4, .62, 17.5, "accent", True)
1950
+ items = page["items"]
1951
+ width = 3.78
1952
+ for i, item in enumerate(items):
1953
+ x = .82 + i * 4.18
1954
+ self._probation_panel(slide, x, 2.28, width, 3.55, rounded=True)
1955
+ self.text(slide, f"{i + 1:02d}", x + .22, 2.52, .6, .4, 22, "accent", True)
1956
+ self.text(slide, item["title"], x + .22, 3.03, width - .44, .42,
1957
+ 19, "text", True)
1958
+ if item.get("tag"):
1959
+ self._probation_tag(slide, item["tag"], x + .22, 3.58,
1960
+ min(1.55, width - .44))
1961
+ self.text(slide, item["body"], x + .22, 4.08, width - .44, 1.35,
1962
+ 14.5)
1963
+ self._probation_panel(slide, .82, 6.02, 11.7, .62, rounded=False)
1964
+ self.text(slide, page.get("bottom", "从代码产出到现场验证,形成可复用的机器人能力。"),
1965
+ 1.05, 6.18, 11.2, .35, 12.5, "text", True)
1966
+ elif layout == "improvement":
1967
+ self.text(slide, page["lead"], .84, 1.57, 11.4, .42, 19, "accent", True)
1968
+ self.box(slide, .8, 2.25, 11.7, .62, "accent")
1969
+ for label, x, w in [("改进方向", 1.0, 2.1), ("当前不足", 3.25, 3.8), ("具体行动", 7.65, 4.3)]:
1970
+ self.text(slide, label, x, 2.43, w, .31, 16, "bg", True)
1971
+ for i, item in enumerate(page["items"]):
1972
+ y = 2.92 + i * 1.08
1973
+ self.box(slide, .8, y, 11.7, 1.03, "panel")
1974
+ before, action = item["body"].split("\n", 1)
1975
+ self.text(slide, item["title"], 1.0, y + .25, 2.1, .43, 19, "accent", True)
1976
+ self.text(slide, before.removeprefix("现状:"), 3.25, y + .18, 3.8, .77, 17)
1977
+ self.text(slide, action.removeprefix("动作:"), 7.65, y + .18, 4.3, .77, 17)
1978
+ elif layout == "roadmap":
1979
+ # Match the supplied template's growth page: one horizontal
1980
+ # timeline, three colored milestones, and aligned bullet tracks.
1981
+ milestone_colors = ["2DBE62", "4285E8", "F36C2B"]
1982
+ xs = [.82, 4.65, 8.48]
1983
+ centers = [1.82, 5.65, 9.48]
1984
+ self.box(slide, 1.82, 2.43, 9.75, .035, "text")
1985
+ for i, (item, x, center, color) in enumerate(
1986
+ zip(page["items"], xs, centers, milestone_colors)):
1987
+ outer = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(center - .23),
1988
+ Inches(2.19), Inches(.46), Inches(.46))
1989
+ outer.fill.solid()
1990
+ outer.fill.fore_color.rgb = RGBColor.from_string("0B1324")
1991
+ outer.line.color.rgb = RGBColor.from_string(color)
1992
+ outer.line.width = Pt(3.2)
1993
+ self.text(slide, f"{i + 1:02d}", center - .16, 2.28, .32, .24,
1994
+ 9, color, True)
1995
+ title = item["title"].replace("|", " | ")
1996
+ self.text(slide, title, x + .52, 1.84, 2.75, .42, 18, color, True)
1997
+ self.box(slide, x, 3.58, 3.2, .012, color)
1998
+ parts = [part.strip() for part in item["body"].replace(";", "\n").split("\n")
1999
+ if part.strip()]
2000
+ for j, part in enumerate(parts[:3]):
2001
+ y = 3.86 + j * .72
2002
+ bullet = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(x), Inches(y + .08),
2003
+ Inches(.09), Inches(.09))
2004
+ bullet.fill.solid()
2005
+ bullet.fill.fore_color.rgb = RGBColor.from_string(color)
2006
+ bullet.line.fill.background()
2007
+ self.text(slide, part, x + .26, y, 3.0, .5, 13.5, "text")
2008
+ self.box(slide, .82, 6.48, 11.7, .012, "text")
2009
+ self.text(slide, page.get("bottom", "按阶段推进,从当前工作延伸到通用机器人应用。"),
2010
+ 3.05, 6.67, 8.5, .25, 12.5, "muted")
2011
+ elif layout == "research_plan":
2012
+ for i, item in enumerate(page["items"]):
2013
+ x, y = .82 + (i % 2) * 6.12, 1.65 + (i // 2) * 2.45
2014
+ self._probation_panel(slide, x, y, 5.55, 2.05, rounded=False)
2015
+ self.box(slide, x, y, .065, 2.05, "accent")
2016
+ self.text(slide, item["title"], x + .23, y + .18, 5.0, .4, 21, "accent", True)
2017
+ self.text(slide, item["body"], x + .23, y + .8, 5.0, 1.1, 16)
2018
+ elif layout == "application_plan":
2019
+ for i, item in enumerate(page["models"]):
2020
+ x = .82 + i * 6.12
2021
+ self._probation_card(slide, item["title"], item["body"], x, 1.65,
2022
+ 5.55, 1.4, body_size=15)
2023
+ self.text(slide, "部署与推理服务", .9, 3.33, 3.1, .42, 20, "accent", True)
2024
+ self.text(slide, page["integration"], 4.15, 3.3, 8.1, .65, 17)
2025
+ for i, item in enumerate(page["steps"]):
2026
+ x = .82 + i * 4.15
2027
+ self._probation_card(slide, item["title"], item["body"], x, 4.22,
2028
+ 3.7, 1.65, body_size=15)
2029
+ if i < len(page["steps"]) - 1:
2030
+ self._probation_arrow(slide, x + 3.78, 4.85, .23, .38)
2031
+ self.text(slide, page["note"], .9, 6.43, 11.45, .32, 14, "muted")
2032
+ elif layout == "summary":
2033
+ self._probation_card(slide, page["left_title"], page["left_body"], .82, 1.8, 5.6, 3.65,
2034
+ 1, body_size=15)
2035
+ self._probation_card(slide, page["right_title"], page["right_body"], 6.92, 1.8, 5.6, 3.65,
2036
+ 2, body_size=15)
2037
+ self.text(slide, page["bottom"], .92, 6.0, 11.5, .42, 18, "accent", True)
2038
+ else:
2039
+ raise ValueError(f"unsupported probation layout: {layout}")
2040
+
2041
+
2042
+ def build(spec: dict, base: Path, output: Path) -> None:
2043
+ """Validate and build; only spec assets are read and output is written."""
2044
+ validate(spec, base)
2045
+ builder = DeckBuilder(spec, base)
2046
+ for index, page in enumerate(spec["slides"], 1):
2047
+ page.setdefault("index", index)
2048
+ if page["layout"] in {"image_hero", "image_overlay", "section_number",
2049
+ "image_statement", "split_diagonal", "glass_overlay",
2050
+ "quote_stage", "kpi_showcase", "cutout_statement",
2051
+ "image_annotated", "image_arch"}:
2052
+ slide = builder.blank(page, index)
2053
+ else:
2054
+ slide = builder.new(page, index)
2055
+ # Stable page identity lives in the slide name: it survives reordering
2056
+ # and gives the edit ledger something position-independent to match on.
2057
+ slide._element.cSld.set("name", page.get("id", f"P{index:02d}"))
2058
+ builder.compose(slide, page)
2059
+ effect = spec["meta"].get("transition")
2060
+ if effect:
2061
+ builder.transition(slide, effect)
2062
+ output.parent.mkdir(parents=True, exist_ok=True)
2063
+ builder.prs.save(str(output))
2064
+
2065
+
2066
+ def _template_page_map(spec: dict, base: Path, template: Presentation) -> list[dict]:
2067
+ """Validate a one-to-one page map for template-preserving generation."""
2068
+ pages = spec.get("slides")
2069
+ if not isinstance(pages, list) or len(pages) != len(template.slides):
2070
+ raise ValueError("template mode requires one slide spec for every template slide")
2071
+ seen: set[int] = set()
2072
+ for index, page in enumerate(pages, 1):
2073
+ if not isinstance(page, dict):
2074
+ raise ValueError(f"slide {index}: template page spec must be an object")
2075
+ source_index = page.get("template_slide")
2076
+ if type(source_index) is not int or not 1 <= source_index <= len(template.slides):
2077
+ raise ValueError(f"slide {index}: template_slide must be a valid 1-based slide number")
2078
+ if source_index in seen:
2079
+ raise ValueError(f"slide {index}: template_slide {source_index} is used more than once")
2080
+ seen.add(source_index)
2081
+ for field in ("text", "images"):
2082
+ values = page.get(field, {})
2083
+ if not isinstance(values, dict) or any(not isinstance(k, str) for k in values):
2084
+ raise ValueError(f"slide {index}: {field} must map shape names to values")
2085
+ slide = template.slides[source_index - 1]
2086
+ shapes = {shape.name: shape for shape in slide.shapes}
2087
+ for name, value in page.get("text", {}).items():
2088
+ if name not in shapes or not getattr(shapes[name], "has_text_frame", False):
2089
+ raise ValueError(f"slide {index}: text target is not a text shape: {name}")
2090
+ if not isinstance(value, str):
2091
+ raise ValueError(f"slide {index}: text for {name} must be a string")
2092
+ for name, filename in page.get("images", {}).items():
2093
+ if name not in shapes or shapes[name].shape_type != 13:
2094
+ raise ValueError(f"slide {index}: image target is not a picture shape: {name}")
2095
+ image_path = (Path(filename) if Path(filename).is_absolute() else base / filename).resolve()
2096
+ if not image_path.is_file():
2097
+ raise ValueError(f"slide {index}: image not found: {image_path}")
2098
+ with Image.open(image_path) as picture:
2099
+ picture.verify()
2100
+ notes = page.get("notes", "")
2101
+ if not isinstance(notes, str):
2102
+ raise ValueError(f"slide {index}: notes must be a string")
2103
+ if seen != set(range(1, len(template.slides) + 1)):
2104
+ raise ValueError("template_slide values must cover every template slide exactly once")
2105
+ return pages
2106
+
2107
+
2108
+ def _replace_text(shape, value: str) -> None:
2109
+ """Replace text while retaining the first run's font and paragraph styling."""
2110
+ frame = shape.text_frame
2111
+ runs = [run for paragraph in frame.paragraphs for run in paragraph.runs]
2112
+ if runs:
2113
+ runs[0].text = value
2114
+ for run in runs[1:]:
2115
+ run.text = ""
2116
+ for paragraph in frame.paragraphs[1:]:
2117
+ for run in paragraph.runs:
2118
+ run.text = ""
2119
+ else:
2120
+ frame.paragraphs[0].add_run().text = value
2121
+
2122
+
2123
+ def _replace_text_with_style(shape, value: str) -> None:
2124
+ """Replace all text while preserving the existing paragraph/run styling."""
2125
+ frame = shape.text_frame
2126
+ paragraphs = frame.paragraphs
2127
+ if not paragraphs:
2128
+ frame.text = value
2129
+ return
2130
+ first_run = next((run for paragraph in paragraphs for run in paragraph.runs), None)
2131
+ if first_run is None:
2132
+ frame.text = value
2133
+ return
2134
+ first_run.text = value
2135
+ for paragraph in paragraphs:
2136
+ for run in paragraph.runs:
2137
+ if run is not first_run:
2138
+ run.text = ""
2139
+ for paragraph in paragraphs[1:]:
2140
+ paragraph.text = ""
2141
+
2142
+
2143
+ def build_from_template(spec: dict, base: Path, template_path: Path, output: Path) -> None:
2144
+ """Edit named text and picture shapes without rebuilding template slides."""
2145
+ template = Presentation(str(template_path))
2146
+ pages = _template_page_map(spec, base, template)
2147
+ for page in pages:
2148
+ slide = template.slides[page["template_slide"] - 1]
2149
+ shapes = {shape.name: shape for shape in slide.shapes}
2150
+ for name, value in page.get("text", {}).items():
2151
+ _replace_text_with_style(shapes[name], value)
2152
+ for name, filename in page.get("images", {}).items():
2153
+ shape = shapes[name]
2154
+ image_path = (Path(filename) if Path(filename).is_absolute() else base / filename).resolve()
2155
+ image_part, _ = slide.part.get_or_add_image_part(str(image_path))
2156
+ blip = shape._element.blipFill.blip
2157
+ blip.set(qn("r:embed"), slide.part.relate_to(image_part, RT.IMAGE))
2158
+ if "notes" in page:
2159
+ slide.notes_slide.notes_text_frame.text = page["notes"]
2160
+ output.parent.mkdir(parents=True, exist_ok=True)
2161
+ template.save(str(output))
2162
+
2163
+
2164
+ def build_probation_deck_v2(spec: dict, base: Path, template_path: Path, output: Path) -> None:
2165
+ """Keep the reference cover untouched and compose varied native pages."""
2166
+ template = Presentation(str(template_path))
2167
+ if len(template.slides) < 1:
2168
+ raise ValueError("probation mode requires a template with at least one slide")
2169
+ if (abs(template.slide_width / Inches(1) - 13.333333) > .02
2170
+ or abs(template.slide_height / Inches(1) - 7.5) > .02):
2171
+ raise ValueError("native_v2 layouts require a 13.333 × 7.5 inch template")
2172
+ prs = Presentation(str(template_path))
2173
+ # Slide 1 is the supplied cover. Remove only the template's example
2174
+ # content pages; this leaves the original cover XML, artwork and wording
2175
+ # unchanged while retaining its masters and theme for new pages.
2176
+ while len(prs.slides) > 1:
2177
+ slide_id = prs.slides._sldIdLst[-1]
2178
+ prs.part.drop_rel(slide_id.rId)
2179
+ prs.slides._sldIdLst.remove(slide_id)
2180
+ builder = DeckBuilder(spec, base)
2181
+ builder.prs = prs
2182
+ builder.font = spec.get("meta", {}).get("font", "Microsoft YaHei")
2183
+ builder.prs.slide_width = template.slide_width
2184
+ builder.prs.slide_height = template.slide_height
2185
+ used_assets: set[str] = set()
2186
+ pages = spec["slides"]
2187
+ if not pages or pages[0].get("type") != "cover":
2188
+ raise ValueError("native_v2 spec must start with a cover page")
2189
+ for index, page in enumerate(pages[1:], 2):
2190
+ preferred_layout = (0 if page["layout"] == "thanks" else
2191
+ 3 if len(builder.prs.slide_layouts) > 3 else len(builder.prs.slide_layouts) - 1)
2192
+ slide = builder.prs.slides.add_slide(builder.prs.slide_layouts[preferred_layout])
2193
+ for shape in list(slide.shapes):
2194
+ if shape.is_placeholder:
2195
+ shape._element.getparent().remove(shape._element)
2196
+ builder.compose_probation_v2(slide, page, index, used_assets)
2197
+ slide.notes_slide.notes_text_frame.text = page.get("notes", "")
2198
+ output.parent.mkdir(parents=True, exist_ok=True)
2199
+ prs.save(str(output))
2200
+
2201
+
2202
+ def build_probation_deck(spec: dict, base: Path, template_path: Path, output: Path) -> None:
2203
+ """Build a complete editable review deck using the supplied template DNA.
2204
+
2205
+ The template contributes its masters, theme, background and fonts. Content
2206
+ pages are composed by this repository-owned engine from explicit JSON and
2207
+ local assets; no workspace generator is imported or executed.
2208
+ """
2209
+ if spec.get("meta", {}).get("deck_mode") == "native_v2":
2210
+ build_probation_deck_v2(spec, base, template_path, output)
2211
+ return
2212
+ template = Presentation(str(template_path))
2213
+ if len(template.slides) < 1:
2214
+ raise ValueError("probation mode requires a template with at least one slide")
2215
+ reference = template.slides[0]
2216
+ prs = Presentation(str(template_path))
2217
+ while len(prs.slides):
2218
+ slide_id = prs.slides._sldIdLst[-1]
2219
+ prs.part.drop_rel(slide_id.rId)
2220
+ prs.slides._sldIdLst.remove(slide_id)
2221
+ builder = DeckBuilder(spec, base)
2222
+ builder.prs = prs
2223
+ builder.font = spec.get("meta", {}).get("font", "Microsoft YaHei")
2224
+ builder.prs.slide_width = template.slide_width
2225
+ builder.prs.slide_height = template.slide_height
2226
+ builder.reference = reference
2227
+ for index, page in enumerate(spec["slides"], 1):
2228
+ # The supplied reference deck has only five layouts. Select an
2229
+ # existing layout so the generated pages retain the template master
2230
+ # without assuming a blank layout at a fixed index.
2231
+ preferred_layout = (
2232
+ 0 if page.get("type") == "cover"
2233
+ else 3 if page.get("layout") == "closing"
2234
+ else 1
2235
+ )
2236
+ layout_index = min(preferred_layout, len(builder.prs.slide_layouts) - 1)
2237
+ slide = builder.prs.slides.add_slide(builder.prs.slide_layouts[layout_index])
2238
+ slide.background.fill.solid()
2239
+ slide.background.fill.fore_color.rgb = RGBColor.from_string("F4F7FB")
2240
+ builder.text(slide, page.get("title", spec["meta"]["title"]), .62, .38, 10.8, .45, 25, "text", True)
2241
+ builder.box(slide, .62, 1.0, 1.15, .06, "accent")
2242
+ if page.get("subtitle") and page.get("type") not in {"cover", "closing"}:
2243
+ builder.text(slide, page["subtitle"], 1.92, .85, 10.7, .28, 10.5, "muted")
2244
+ builder.text(slide, "人形机器人(上海)有限公司 | 轮臂-应用开发 | 赵文辉", .55, 7.12, 10.5, .18, 8.5, "muted")
2245
+ builder.text(slide, f"{index:02d}", 12.25, 7.08, .45, .22, 9, "muted")
2246
+ if page.get("type") == "cover":
2247
+ builder.text(slide, page.get("body", ""), .8, 1.8, 7.0, 2.5, 27, "accent", True)
2248
+ builder.text(slide, page.get("subtitle", ""), .8, 4.45, 7.0, 1.2, 15, "muted")
2249
+ if page.get("image"):
2250
+ builder.picture(slide, page["image"], 8.4, 1.4, 4.0, 4.9)
2251
+ elif page.get("type") == "closing":
2252
+ builder.text(slide, page.get("body", "谢谢聆听"), .8, 2.2, 8.5, 1.5, 30, "accent", True)
2253
+ builder.text(slide, page.get("subtitle", ""), .8, 3.65, 8.5, 1.2, 17, "muted")
2254
+ else:
2255
+ builder.compose(slide, page)
2256
+ slide.notes_slide.notes_text_frame.text = page.get("notes", "")
2257
+ output.parent.mkdir(parents=True, exist_ok=True)
2258
+ prs.save(str(output))
2259
+
2260
+
2261
+ def main() -> int:
2262
+ parser = argparse.ArgumentParser(description=__doc__)
2263
+ parser.add_argument("spec", type=Path)
2264
+ parser.add_argument("output", type=Path)
2265
+ parser.add_argument("--template", type=Path,
2266
+ help="reuse every slide from this PPTX, replacing named text/image shapes")
2267
+ parser.add_argument("--probation", action="store_true",
2268
+ help="compose a complete probation deck while retaining the template theme and masters")
2269
+ args = parser.parse_args()
2270
+ try:
2271
+ spec = json.loads(args.spec.read_text(encoding="utf-8"))
2272
+ base = args.spec.resolve().parent
2273
+ if args.probation:
2274
+ if not args.template:
2275
+ parser.error("--probation requires --template")
2276
+ build_probation_deck(spec, base, args.template.resolve(), args.output.resolve())
2277
+ elif args.template:
2278
+ build_from_template(spec, base, args.template.resolve(), args.output.resolve())
2279
+ else:
2280
+ build(spec, base, args.output.resolve())
2281
+ except (ValueError, OSError, KeyError, TypeError) as exc:
2282
+ parser.exit(2, f"build_deck: {exc}\n")
2283
+ print(f"Generated {len(spec['slides'])} editable slides: {args.output.resolve()}")
2284
+ return 0
2285
+
2286
+
2287
+ if __name__ == "__main__":
2288
+ raise SystemExit(main())