@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- 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())
|