skilldrop-cli 0.12.0 → 0.12.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/README.md +1 -1
- package/bin/skilldrop.js +38 -1
- package/model-routing.json +1 -1
- package/package.json +1 -1
- package/skills/deck-builder/SKILL.md +160 -93
- package/skills/deck-builder/evals/eval_queries.json +12 -32
- package/skills/deck-builder/evals/evals.json +23 -0
- package/skills/deck-builder/examples/templated-brand-deck.md +148 -0
- package/skills/deck-builder/manifest.json +5 -5
- package/skills/deck-builder/reference.md +88 -0
- package/skills/deck-builder/scripts/build_deck.py +863 -140
- package/skills/deck-builder/templates/deck-spec.json +69 -1
|
@@ -2,32 +2,53 @@
|
|
|
2
2
|
"""Build a .pptx from a JSON deck spec.
|
|
3
3
|
|
|
4
4
|
The spec schema is documented in templates/deck-spec.json. Top-level fields:
|
|
5
|
-
- title:
|
|
6
|
-
- audience:
|
|
7
|
-
-
|
|
8
|
-
-
|
|
5
|
+
- title: overall deck title
|
|
6
|
+
- audience: archetype (informational; doesn't change rendering)
|
|
7
|
+
- aspect: "16:9" (default) or "4:3" — ignored when a template is supplied
|
|
8
|
+
- template: path to a .pptx/.potx brand template; its masters, theme fonts,
|
|
9
|
+
colors and slide size are inherited
|
|
10
|
+
- layout_map: logical layout -> template layout name or index (template mode)
|
|
11
|
+
- palette: a hex-color block, or a name resolvable from templates/palettes.json
|
|
12
|
+
- slide_numbers: true/false (default: true when the deck has more than 10 slides)
|
|
13
|
+
- slides: list of slide specs, each with a `layout` field
|
|
9
14
|
|
|
10
|
-
Supported layouts: title, section, content, two_column, big_number, quote,
|
|
15
|
+
Supported layouts: title, section, content, two_column, big_number, quote,
|
|
16
|
+
image, table, chart, closing.
|
|
11
17
|
|
|
12
18
|
Usage:
|
|
13
|
-
python3 build_deck.py <spec.json> [-o output.pptx]
|
|
19
|
+
python3 build_deck.py <spec.json> [-o output.pptx] [--strict]
|
|
14
20
|
"""
|
|
15
21
|
|
|
16
22
|
from __future__ import annotations
|
|
17
23
|
|
|
18
24
|
import argparse
|
|
25
|
+
import copy
|
|
19
26
|
import json
|
|
27
|
+
import shutil
|
|
20
28
|
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import zipfile
|
|
31
|
+
from dataclasses import dataclass
|
|
21
32
|
from pathlib import Path
|
|
22
33
|
|
|
23
34
|
from pptx import Presentation
|
|
35
|
+
from pptx.chart.data import CategoryChartData
|
|
24
36
|
from pptx.dml.color import RGBColor
|
|
25
|
-
from pptx.enum.
|
|
26
|
-
from pptx.enum.
|
|
27
|
-
from pptx.
|
|
37
|
+
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
|
38
|
+
from pptx.enum.shapes import MSO_SHAPE, PP_PLACEHOLDER
|
|
39
|
+
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
40
|
+
from pptx.util import Emu, Inches, Pt
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
# The design canvas every hardcoded position below is expressed in. Real slide
|
|
43
|
+
# geometry comes from Canvas, which scales these to the actual deck size.
|
|
44
|
+
DESIGN_W_IN = 13.333
|
|
45
|
+
DESIGN_H_IN = 7.5
|
|
46
|
+
|
|
47
|
+
ASPECTS = {
|
|
48
|
+
"16:9": (13.333, 7.5),
|
|
49
|
+
"4:3": (10.0, 7.5),
|
|
50
|
+
"16:10": (12.0, 7.5),
|
|
51
|
+
}
|
|
31
52
|
|
|
32
53
|
DEFAULT_PALETTE = {
|
|
33
54
|
"primary": "#1A2A6C",
|
|
@@ -37,9 +58,89 @@ DEFAULT_PALETTE = {
|
|
|
37
58
|
"text": "#222222",
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
CHART_TYPES = {
|
|
62
|
+
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
63
|
+
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
|
|
64
|
+
"line": XL_CHART_TYPE.LINE_MARKERS,
|
|
65
|
+
"pie": XL_CHART_TYPE.PIE,
|
|
66
|
+
"doughnut": XL_CHART_TYPE.DOUGHNUT,
|
|
67
|
+
"stacked_column": XL_CHART_TYPE.COLUMN_STACKED,
|
|
68
|
+
"stacked_bar": XL_CHART_TYPE.BAR_STACKED,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
RASTER_OK = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"}
|
|
72
|
+
|
|
73
|
+
WARNINGS: list[str] = []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def warn(msg: str) -> None:
|
|
77
|
+
WARNINGS.append(msg)
|
|
78
|
+
sys.stderr.write(f"warning: {msg}\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def die(msg: str) -> None:
|
|
82
|
+
sys.stderr.write(f"error: {msg}\n")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ----------------------------------------------------------------- geometry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Canvas:
|
|
91
|
+
"""Maps design-canvas inches onto the real slide, whatever size it is."""
|
|
92
|
+
|
|
93
|
+
w: int # EMU
|
|
94
|
+
h: int # EMU
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def xr(self) -> float:
|
|
98
|
+
return self.w / Inches(DESIGN_W_IN)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def yr(self) -> float:
|
|
102
|
+
return self.h / Inches(DESIGN_H_IN)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def fr(self) -> float:
|
|
106
|
+
"""Font scale — driven by the tighter axis, floored so text stays legible."""
|
|
107
|
+
return max(0.7, min(self.xr, self.yr))
|
|
108
|
+
|
|
109
|
+
def x(self, inches: float) -> int:
|
|
110
|
+
return int(Inches(inches) * self.xr)
|
|
111
|
+
|
|
112
|
+
def y(self, inches: float) -> int:
|
|
113
|
+
return int(Inches(inches) * self.yr)
|
|
114
|
+
|
|
115
|
+
def pt(self, size: float) -> Pt:
|
|
116
|
+
return Pt(round(size * self.fr, 1))
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def right_margin_w(self) -> int:
|
|
120
|
+
"""Full content width at the standard 0.75in gutter."""
|
|
121
|
+
return self.w - 2 * self.x(0.75)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Region:
|
|
126
|
+
"""A rectangle content gets drawn into."""
|
|
127
|
+
|
|
128
|
+
left: int
|
|
129
|
+
top: int
|
|
130
|
+
width: int
|
|
131
|
+
height: int
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def default_region(c: Canvas, *, below_title_bar: bool = True) -> Region:
|
|
135
|
+
top = c.y(1.5) if below_title_bar else c.y(0.9)
|
|
136
|
+
return Region(c.x(0.75), top, c.right_margin_w, c.h - top - c.y(0.6))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ----------------------------------------------------------------- palette
|
|
140
|
+
|
|
40
141
|
|
|
41
142
|
def hex_color(s: str) -> RGBColor:
|
|
42
|
-
return RGBColor.from_string(s.lstrip("#"))
|
|
143
|
+
return RGBColor.from_string(str(s).lstrip("#").upper())
|
|
43
144
|
|
|
44
145
|
|
|
45
146
|
def resolve_palette(p, script_dir: Path) -> dict:
|
|
@@ -49,16 +150,240 @@ def resolve_palette(p, script_dir: Path) -> dict:
|
|
|
49
150
|
if isinstance(p, str):
|
|
50
151
|
palettes_path = script_dir.parent / "templates" / "palettes.json"
|
|
51
152
|
if not palettes_path.exists():
|
|
52
|
-
|
|
153
|
+
warn(f"palettes file missing at {palettes_path}; using defaults")
|
|
53
154
|
return DEFAULT_PALETTE
|
|
54
155
|
all_palettes = json.loads(palettes_path.read_text())
|
|
55
156
|
if p not in all_palettes:
|
|
56
|
-
|
|
157
|
+
warn(f"palette {p!r} not found; using defaults")
|
|
57
158
|
return DEFAULT_PALETTE
|
|
58
159
|
return {**DEFAULT_PALETTE, **all_palettes[p]}
|
|
59
160
|
return DEFAULT_PALETTE
|
|
60
161
|
|
|
61
162
|
|
|
163
|
+
def series_colors(palette: dict, n: int) -> list:
|
|
164
|
+
base = [palette["primary"], palette["accent"], palette["secondary"],
|
|
165
|
+
palette["text"], "#7A8BB5", "#C9A227"]
|
|
166
|
+
return [base[i % len(base)] for i in range(n)]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ----------------------------------------------------------------- template
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _potx_to_pptx(src: Path, tmpdir: Path) -> Path:
|
|
173
|
+
"""python-pptx refuses a .potx content type. Repack it as a .pptx."""
|
|
174
|
+
dst = tmpdir / (src.stem + ".pptx")
|
|
175
|
+
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
176
|
+
for item in zin.infolist():
|
|
177
|
+
data = zin.read(item.filename)
|
|
178
|
+
if item.filename == "[Content_Types].xml":
|
|
179
|
+
text = data.decode("utf-8")
|
|
180
|
+
text = text.replace(
|
|
181
|
+
"presentationml.template.main+xml",
|
|
182
|
+
"presentationml.presentation.main+xml",
|
|
183
|
+
).replace(
|
|
184
|
+
"presentationml.template.macroEnabled.main+xml",
|
|
185
|
+
"presentationml.presentation.macroEnabled.main+xml",
|
|
186
|
+
)
|
|
187
|
+
data = text.encode("utf-8")
|
|
188
|
+
zout.writestr(item, data)
|
|
189
|
+
return dst
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def clear_slides(prs) -> int:
|
|
193
|
+
"""Drop any slides the template shipped with — we want its masters, not its content."""
|
|
194
|
+
sld_id_lst = prs.slides._sldIdLst
|
|
195
|
+
removed = 0
|
|
196
|
+
for sld_id in list(sld_id_lst):
|
|
197
|
+
prs.part.drop_rel(sld_id.rId)
|
|
198
|
+
sld_id_lst.remove(sld_id)
|
|
199
|
+
removed += 1
|
|
200
|
+
return removed
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def open_presentation(spec: dict, spec_dir: Path, tmpdir: Path):
|
|
204
|
+
"""Return (prs, canvas, using_template)."""
|
|
205
|
+
tmpl = spec.get("template")
|
|
206
|
+
if not tmpl:
|
|
207
|
+
prs = Presentation()
|
|
208
|
+
aspect = spec.get("aspect", "16:9")
|
|
209
|
+
if aspect not in ASPECTS:
|
|
210
|
+
warn(f"unknown aspect {aspect!r}; using 16:9")
|
|
211
|
+
aspect = "16:9"
|
|
212
|
+
w_in, h_in = ASPECTS[aspect]
|
|
213
|
+
prs.slide_width = Inches(w_in)
|
|
214
|
+
prs.slide_height = Inches(h_in)
|
|
215
|
+
return prs, Canvas(prs.slide_width, prs.slide_height), False
|
|
216
|
+
|
|
217
|
+
path = Path(tmpl).expanduser()
|
|
218
|
+
if not path.is_absolute():
|
|
219
|
+
path = (spec_dir / path).resolve()
|
|
220
|
+
if not path.exists():
|
|
221
|
+
die(f"template not found: {path}")
|
|
222
|
+
if path.suffix.lower() in (".potx", ".potm"):
|
|
223
|
+
path = _potx_to_pptx(path, tmpdir)
|
|
224
|
+
elif path.suffix.lower() not in (".pptx", ".pptm"):
|
|
225
|
+
die(f"template must be .pptx or .potx, got {path.suffix!r}")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
prs = Presentation(str(path))
|
|
229
|
+
except Exception as exc: # noqa: BLE001 — surface the real reason, don't mask it
|
|
230
|
+
die(f"could not open template {path}: {exc}")
|
|
231
|
+
|
|
232
|
+
dropped = clear_slides(prs)
|
|
233
|
+
if dropped:
|
|
234
|
+
print(f"template: dropped {dropped} sample slide(s), kept masters and layouts")
|
|
235
|
+
if spec.get("aspect"):
|
|
236
|
+
warn("`aspect` ignored — the template's own slide size wins")
|
|
237
|
+
return prs, Canvas(prs.slide_width, prs.slide_height), True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def list_layouts(prs) -> str:
|
|
241
|
+
return "\n".join(f" [{i}] {lay.name}" for i, lay in enumerate(prs.slide_layouts))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def find_layout(prs, ref):
|
|
245
|
+
"""Resolve a template layout by index, exact name, or case-insensitive substring."""
|
|
246
|
+
if isinstance(ref, int):
|
|
247
|
+
try:
|
|
248
|
+
return prs.slide_layouts[ref]
|
|
249
|
+
except IndexError:
|
|
250
|
+
warn(f"layout index {ref} out of range ({len(prs.slide_layouts)} layouts)")
|
|
251
|
+
return None
|
|
252
|
+
if not isinstance(ref, str):
|
|
253
|
+
return None
|
|
254
|
+
for lay in prs.slide_layouts:
|
|
255
|
+
if lay.name == ref:
|
|
256
|
+
return lay
|
|
257
|
+
low = ref.lower()
|
|
258
|
+
for lay in prs.slide_layouts:
|
|
259
|
+
if lay.name.lower() == low:
|
|
260
|
+
return lay
|
|
261
|
+
for lay in prs.slide_layouts:
|
|
262
|
+
if low in lay.name.lower():
|
|
263
|
+
return lay
|
|
264
|
+
warn(f"template layout {ref!r} not found; falling back to overlay rendering")
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def blank_layout(prs):
|
|
269
|
+
"""The emptiest layout available — index 6 in the default template."""
|
|
270
|
+
for lay in prs.slide_layouts:
|
|
271
|
+
if not lay.placeholders._element.findall(
|
|
272
|
+
".//{http://schemas.openxmlformats.org/presentationml/2006/main}sp"
|
|
273
|
+
):
|
|
274
|
+
return lay
|
|
275
|
+
for lay in prs.slide_layouts:
|
|
276
|
+
if lay.name.strip().lower() in ("blank", "blank slide"):
|
|
277
|
+
return lay
|
|
278
|
+
try:
|
|
279
|
+
return prs.slide_layouts[6]
|
|
280
|
+
except IndexError:
|
|
281
|
+
return prs.slide_layouts[-1]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------- placeholders
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
TITLE_PH = (PP_PLACEHOLDER.TITLE, PP_PLACEHOLDER.CENTER_TITLE)
|
|
288
|
+
BODY_PH = (PP_PLACEHOLDER.BODY, PP_PLACEHOLDER.OBJECT, PP_PLACEHOLDER.SUBTITLE)
|
|
289
|
+
SKIP_PH = (PP_PLACEHOLDER.SLIDE_NUMBER, PP_PLACEHOLDER.FOOTER, PP_PLACEHOLDER.DATE)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def ph_type(shape):
|
|
293
|
+
try:
|
|
294
|
+
return shape.placeholder_format.type
|
|
295
|
+
except (AttributeError, ValueError):
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def placeholders(slide, kinds=None):
|
|
300
|
+
out = []
|
|
301
|
+
for shape in slide.placeholders:
|
|
302
|
+
t = ph_type(shape)
|
|
303
|
+
if t in SKIP_PH:
|
|
304
|
+
continue
|
|
305
|
+
if kinds is None or t in kinds:
|
|
306
|
+
out.append(shape)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def set_ph_text(shape, text: str, *, size_pt=None) -> None:
|
|
311
|
+
"""Write into a placeholder without flattening the template's formatting.
|
|
312
|
+
|
|
313
|
+
Assigning `text_frame.text` collapses the paragraph to one unstyled run and
|
|
314
|
+
loses the template's font. Reusing the existing run keeps it.
|
|
315
|
+
"""
|
|
316
|
+
tf = shape.text_frame
|
|
317
|
+
tf.word_wrap = True
|
|
318
|
+
p = tf.paragraphs[0]
|
|
319
|
+
for extra in list(tf.paragraphs)[1:]:
|
|
320
|
+
extra._p.getparent().remove(extra._p)
|
|
321
|
+
if p.runs:
|
|
322
|
+
p.runs[0].text = text
|
|
323
|
+
for extra in list(p.runs)[1:]:
|
|
324
|
+
extra._r.getparent().remove(extra._r)
|
|
325
|
+
run = p.runs[0]
|
|
326
|
+
else:
|
|
327
|
+
run = p.add_run()
|
|
328
|
+
run.text = text
|
|
329
|
+
if size_pt is not None:
|
|
330
|
+
run.font.size = size_pt
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def set_ph_bullets(shape, bullets, *, bold_first: bool = False) -> None:
|
|
334
|
+
tf = shape.text_frame
|
|
335
|
+
tf.word_wrap = True
|
|
336
|
+
template_p = copy.deepcopy(tf.paragraphs[0]._p)
|
|
337
|
+
for extra in list(tf.paragraphs)[1:]:
|
|
338
|
+
extra._p.getparent().remove(extra._p)
|
|
339
|
+
for i, text in enumerate(bullets):
|
|
340
|
+
if i == 0:
|
|
341
|
+
p = tf.paragraphs[0]
|
|
342
|
+
else:
|
|
343
|
+
tf._txBody.append(copy.deepcopy(template_p))
|
|
344
|
+
p = tf.paragraphs[-1]
|
|
345
|
+
if p.runs:
|
|
346
|
+
p.runs[0].text = str(text)
|
|
347
|
+
for extra in list(p.runs)[1:]:
|
|
348
|
+
extra._r.getparent().remove(extra._r)
|
|
349
|
+
else:
|
|
350
|
+
p.add_run().text = str(text)
|
|
351
|
+
if bold_first and i == 0 and p.runs:
|
|
352
|
+
p.runs[0].font.bold = True
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def drop_empty_placeholders(slide) -> None:
|
|
356
|
+
"""Remove the 'Click to add text' ghosts a template layout leaves behind."""
|
|
357
|
+
for shape in list(slide.placeholders):
|
|
358
|
+
if ph_type(shape) in SKIP_PH:
|
|
359
|
+
continue
|
|
360
|
+
if shape.has_text_frame and not shape.text_frame.text.strip():
|
|
361
|
+
shape._element.getparent().remove(shape._element)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def claim_body_region(slide, c: Canvas, *, below_title_bar=True, kinds=BODY_PH) -> Region:
|
|
365
|
+
"""Take the biggest empty body placeholder's rectangle and remove the placeholder.
|
|
366
|
+
|
|
367
|
+
Custom content (numbers, tables, charts, images) then lands exactly where the
|
|
368
|
+
template intended content to go, instead of at our own hardcoded coordinates.
|
|
369
|
+
"""
|
|
370
|
+
best, best_area = None, 0
|
|
371
|
+
for shape in placeholders(slide, kinds):
|
|
372
|
+
if shape.has_text_frame and shape.text_frame.text.strip():
|
|
373
|
+
continue
|
|
374
|
+
area = (shape.width or 0) * (shape.height or 0)
|
|
375
|
+
if area > best_area:
|
|
376
|
+
best, best_area = shape, area
|
|
377
|
+
if best is None or not best_area:
|
|
378
|
+
return default_region(c, below_title_bar=below_title_bar)
|
|
379
|
+
region = Region(best.left, best.top, best.width, best.height)
|
|
380
|
+
best._element.getparent().remove(best._element)
|
|
381
|
+
return region
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------- draw helpers
|
|
385
|
+
|
|
386
|
+
|
|
62
387
|
def set_background(slide, hex_str: str) -> None:
|
|
63
388
|
fill = slide.background.fill
|
|
64
389
|
fill.solid()
|
|
@@ -66,22 +391,24 @@ def set_background(slide, hex_str: str) -> None:
|
|
|
66
391
|
|
|
67
392
|
|
|
68
393
|
def add_textbox(slide, text: str, *, left, top, width, height,
|
|
69
|
-
size
|
|
70
|
-
align=PP_ALIGN.LEFT) -> None:
|
|
394
|
+
size, bold: bool = False, color: str = "#222222",
|
|
395
|
+
align=PP_ALIGN.LEFT, anchor=None) -> None:
|
|
71
396
|
box = slide.shapes.add_textbox(left, top, width, height)
|
|
72
397
|
tf = box.text_frame
|
|
73
398
|
tf.word_wrap = True
|
|
399
|
+
if anchor is not None:
|
|
400
|
+
tf.vertical_anchor = anchor
|
|
74
401
|
p = tf.paragraphs[0]
|
|
75
402
|
p.alignment = align
|
|
76
403
|
run = p.add_run()
|
|
77
404
|
run.text = text
|
|
78
|
-
run.font.size =
|
|
405
|
+
run.font.size = size
|
|
79
406
|
run.font.bold = bold
|
|
80
407
|
run.font.color.rgb = hex_color(color)
|
|
81
408
|
|
|
82
409
|
|
|
83
410
|
def add_bullets(slide, bullets, *, left, top, width, height,
|
|
84
|
-
size
|
|
411
|
+
size, color: str = "#222222") -> None:
|
|
85
412
|
box = slide.shapes.add_textbox(left, top, width, height)
|
|
86
413
|
tf = box.text_frame
|
|
87
414
|
tf.word_wrap = True
|
|
@@ -90,7 +417,7 @@ def add_bullets(slide, bullets, *, left, top, width, height,
|
|
|
90
417
|
p.text = f"• {b}"
|
|
91
418
|
p.space_after = Pt(10)
|
|
92
419
|
for run in p.runs:
|
|
93
|
-
run.font.size =
|
|
420
|
+
run.font.size = size
|
|
94
421
|
run.font.color.rgb = hex_color(color)
|
|
95
422
|
|
|
96
423
|
|
|
@@ -99,141 +426,386 @@ def add_rect(slide, *, left, top, width, height, fill: str) -> None:
|
|
|
99
426
|
shape.fill.solid()
|
|
100
427
|
shape.fill.fore_color.rgb = hex_color(fill)
|
|
101
428
|
shape.line.fill.background()
|
|
429
|
+
shape.shadow.inherit = False
|
|
430
|
+
return shape
|
|
102
431
|
|
|
103
432
|
|
|
104
433
|
def set_notes(slide, notes: str) -> None:
|
|
105
|
-
|
|
106
|
-
|
|
434
|
+
slide.notes_slide.notes_text_frame.text = notes
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def add_slide_number(slide, c: Canvas, n: int, palette: dict) -> None:
|
|
438
|
+
add_textbox(slide, str(n),
|
|
439
|
+
left=c.w - c.x(1.0), top=c.h - c.y(0.55),
|
|
440
|
+
width=c.x(0.5), height=c.y(0.35),
|
|
441
|
+
size=c.pt(11), color=palette["text"], align=PP_ALIGN.RIGHT)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------ media helpers
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def place_image(slide, path_str: str, region: Region, *, spec_dir: Path,
|
|
448
|
+
palette: dict, strict: bool):
|
|
449
|
+
"""Aspect-fit an image into the region. Missing/unsupported files degrade visibly."""
|
|
450
|
+
path = Path(path_str).expanduser()
|
|
451
|
+
if not path.is_absolute():
|
|
452
|
+
path = (spec_dir / path).resolve()
|
|
453
|
+
|
|
454
|
+
problem = None
|
|
455
|
+
if not path.exists():
|
|
456
|
+
problem = f"image not found: {path}"
|
|
457
|
+
elif path.suffix.lower() not in RASTER_OK:
|
|
458
|
+
problem = (f"unsupported image format {path.suffix!r} ({path.name}) — "
|
|
459
|
+
"python-pptx reads raster only; convert SVG/EMF/PDF to PNG first")
|
|
460
|
+
|
|
461
|
+
if problem:
|
|
462
|
+
if strict:
|
|
463
|
+
die(problem)
|
|
464
|
+
warn(problem + " — drew a placeholder box instead")
|
|
465
|
+
box = add_rect(slide, left=region.left, top=region.top,
|
|
466
|
+
width=region.width, height=region.height,
|
|
467
|
+
fill="#EDEFF4")
|
|
468
|
+
box.line.color.rgb = hex_color(palette["secondary"])
|
|
469
|
+
box.line.width = Pt(1)
|
|
470
|
+
add_textbox(slide, f"[ missing image: {path.name} ]",
|
|
471
|
+
left=region.left, top=region.top + region.height // 2,
|
|
472
|
+
width=region.width, height=Emu(int(Inches(0.5))),
|
|
473
|
+
size=Pt(14), color=palette["secondary"], align=PP_ALIGN.CENTER)
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
pic = slide.shapes.add_picture(str(path), region.left, region.top)
|
|
477
|
+
scale = min(region.width / pic.width, region.height / pic.height)
|
|
478
|
+
pic.width = int(pic.width * scale)
|
|
479
|
+
pic.height = int(pic.height * scale)
|
|
480
|
+
pic.left = region.left + (region.width - pic.width) // 2
|
|
481
|
+
pic.top = region.top + (region.height - pic.height) // 2
|
|
482
|
+
return pic
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def place_table(slide, columns, rows, region: Region, c: Canvas, palette: dict) -> None:
|
|
486
|
+
n_rows = len(rows) + 1
|
|
487
|
+
n_cols = max(len(columns), max((len(r) for r in rows), default=0))
|
|
488
|
+
gfx = slide.shapes.add_table(n_rows, n_cols, region.left, region.top,
|
|
489
|
+
region.width, region.height)
|
|
490
|
+
table = gfx.table
|
|
491
|
+
table.first_row = True
|
|
492
|
+
|
|
493
|
+
body_pt = 16 if n_rows <= 7 else (14 if n_rows <= 10 else 12)
|
|
494
|
+
|
|
495
|
+
for j in range(n_cols):
|
|
496
|
+
cell = table.cell(0, j)
|
|
497
|
+
cell.text = str(columns[j]) if j < len(columns) else ""
|
|
498
|
+
cell.fill.solid()
|
|
499
|
+
cell.fill.fore_color.rgb = hex_color(palette["primary"])
|
|
500
|
+
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
501
|
+
for p in cell.text_frame.paragraphs:
|
|
502
|
+
for run in p.runs:
|
|
503
|
+
run.font.bold = True
|
|
504
|
+
run.font.size = c.pt(body_pt + 1)
|
|
505
|
+
run.font.color.rgb = hex_color("#FFFFFF")
|
|
506
|
+
|
|
507
|
+
for i, row in enumerate(rows, start=1):
|
|
508
|
+
for j in range(n_cols):
|
|
509
|
+
cell = table.cell(i, j)
|
|
510
|
+
cell.text = str(row[j]) if j < len(row) else ""
|
|
511
|
+
cell.fill.solid()
|
|
512
|
+
cell.fill.fore_color.rgb = hex_color("#FFFFFF" if i % 2 else "#F2F4F8")
|
|
513
|
+
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
514
|
+
for p in cell.text_frame.paragraphs:
|
|
515
|
+
for run in p.runs:
|
|
516
|
+
run.font.size = c.pt(body_pt)
|
|
517
|
+
run.font.color.rgb = hex_color(palette["text"])
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def place_chart(slide, chart_spec: dict, region: Region, c: Canvas, palette: dict) -> None:
|
|
521
|
+
kind = str(chart_spec.get("type", "column")).lower()
|
|
522
|
+
if kind not in CHART_TYPES:
|
|
523
|
+
warn(f"unknown chart type {kind!r}; using column")
|
|
524
|
+
kind = "column"
|
|
525
|
+
|
|
526
|
+
categories = chart_spec.get("categories") or []
|
|
527
|
+
series = chart_spec.get("series") or []
|
|
528
|
+
if not categories or not series:
|
|
529
|
+
warn("chart slide has no categories or no series; skipped")
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
data = CategoryChartData()
|
|
533
|
+
data.categories = categories
|
|
534
|
+
for s in series:
|
|
535
|
+
values = list(s.get("values") or [])
|
|
536
|
+
if len(values) != len(categories):
|
|
537
|
+
warn(f"series {s.get('name', '?')!r} has {len(values)} values "
|
|
538
|
+
f"for {len(categories)} categories — padded with gaps")
|
|
539
|
+
values = (values + [None] * len(categories))[:len(categories)]
|
|
540
|
+
data.add_series(str(s.get("name", "Series")), values)
|
|
541
|
+
|
|
542
|
+
gfx = slide.shapes.add_chart(CHART_TYPES[kind], region.left, region.top,
|
|
543
|
+
region.width, region.height, data)
|
|
544
|
+
chart = gfx.chart
|
|
545
|
+
chart.font.size = c.pt(13)
|
|
546
|
+
chart.font.color.rgb = hex_color(palette["text"])
|
|
547
|
+
|
|
548
|
+
is_pie = kind in ("pie", "doughnut")
|
|
549
|
+
chart.has_legend = bool(chart_spec.get("legend", is_pie or len(series) > 1))
|
|
550
|
+
if chart.has_legend:
|
|
551
|
+
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
|
552
|
+
chart.legend.include_in_layout = False
|
|
553
|
+
|
|
554
|
+
colors = series_colors(palette, max(len(series), len(categories)))
|
|
555
|
+
plot = chart.plots[0]
|
|
556
|
+
if is_pie:
|
|
557
|
+
plot.vary_by_categories = True
|
|
558
|
+
for i, point in enumerate(plot.series[0].points):
|
|
559
|
+
point.format.fill.solid()
|
|
560
|
+
point.format.fill.fore_color.rgb = hex_color(colors[i % len(colors)])
|
|
561
|
+
else:
|
|
562
|
+
plot.vary_by_categories = False
|
|
563
|
+
for i, s in enumerate(chart.series):
|
|
564
|
+
s.format.fill.solid()
|
|
565
|
+
s.format.fill.fore_color.rgb = hex_color(colors[i])
|
|
566
|
+
if kind == "line":
|
|
567
|
+
for i, s in enumerate(chart.series):
|
|
568
|
+
s.format.line.color.rgb = hex_color(colors[i])
|
|
569
|
+
|
|
570
|
+
if chart_spec.get("data_labels", is_pie):
|
|
571
|
+
plot.has_data_labels = True
|
|
572
|
+
labels = plot.data_labels
|
|
573
|
+
labels.font.size = c.pt(12)
|
|
574
|
+
if is_pie:
|
|
575
|
+
labels.font.color.rgb = hex_color("#FFFFFF")
|
|
576
|
+
if chart_spec.get("number_format"):
|
|
577
|
+
labels.number_format = chart_spec["number_format"]
|
|
578
|
+
labels.number_format_is_linked = False
|
|
107
579
|
|
|
108
580
|
|
|
109
581
|
# ----------------------------------------------------------------- renderers
|
|
582
|
+
#
|
|
583
|
+
# Each renderer draws the deck's own design. In template mode the chrome comes
|
|
584
|
+
# from the template layout instead, and only the parts a placeholder cannot
|
|
585
|
+
# express are drawn here.
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _title_bar(slide, c: Canvas, palette, title: str) -> None:
|
|
589
|
+
add_rect(slide, left=0, top=0, width=c.w, height=c.y(1.0),
|
|
590
|
+
fill=palette["primary"])
|
|
591
|
+
add_textbox(slide, title,
|
|
592
|
+
left=c.x(0.5), top=c.y(0.22),
|
|
593
|
+
width=c.w - c.x(1.0), height=c.y(0.7),
|
|
594
|
+
size=c.pt(26), bold=True, color="#FFFFFF")
|
|
110
595
|
|
|
111
596
|
|
|
112
|
-
def render_title(slide, palette, spec) -> None:
|
|
597
|
+
def render_title(slide, c, palette, spec, ctx) -> None:
|
|
113
598
|
set_background(slide, palette["background"])
|
|
114
|
-
add_rect(slide, left=0, top=
|
|
599
|
+
add_rect(slide, left=0, top=c.y(3.0), width=c.w, height=c.y(0.15),
|
|
115
600
|
fill=palette["accent"])
|
|
116
601
|
add_textbox(slide, spec.get("title", "Untitled"),
|
|
117
|
-
left=
|
|
118
|
-
width=
|
|
119
|
-
size=44, bold=True, color=palette["primary"])
|
|
602
|
+
left=c.x(0.75), top=c.y(2.0),
|
|
603
|
+
width=c.right_margin_w, height=c.y(1.2),
|
|
604
|
+
size=c.pt(44), bold=True, color=palette["primary"])
|
|
120
605
|
if spec.get("subtitle"):
|
|
121
606
|
add_textbox(slide, spec["subtitle"],
|
|
122
|
-
left=
|
|
123
|
-
width=
|
|
124
|
-
size=24, color=palette["text"])
|
|
125
|
-
|
|
126
|
-
footer = " · ".join([s for s in footer_bits if s])
|
|
607
|
+
left=c.x(0.75), top=c.y(3.4),
|
|
608
|
+
width=c.right_margin_w, height=c.y(0.8),
|
|
609
|
+
size=c.pt(24), color=palette["text"])
|
|
610
|
+
footer = " · ".join([s for s in (spec.get("presenter"), spec.get("date")) if s])
|
|
127
611
|
if footer:
|
|
128
612
|
add_textbox(slide, footer,
|
|
129
|
-
left=
|
|
130
|
-
width=
|
|
131
|
-
size=14, color=palette["text"])
|
|
613
|
+
left=c.x(0.75), top=c.y(6.5),
|
|
614
|
+
width=c.right_margin_w, height=c.y(0.5),
|
|
615
|
+
size=c.pt(14), color=palette["text"])
|
|
132
616
|
|
|
133
617
|
|
|
134
|
-
def render_section(slide, palette, spec) -> None:
|
|
618
|
+
def render_section(slide, c, palette, spec, ctx) -> None:
|
|
135
619
|
set_background(slide, palette["primary"])
|
|
136
620
|
add_textbox(slide, spec.get("title", ""),
|
|
137
|
-
left=
|
|
138
|
-
width=
|
|
139
|
-
size=48, bold=True, color="#FFFFFF")
|
|
140
|
-
add_rect(slide, left=
|
|
141
|
-
width=
|
|
142
|
-
fill=palette["accent"])
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _title_bar(slide, palette, title: str) -> None:
|
|
146
|
-
add_rect(slide, left=0, top=0, width=SLIDE_W, height=Inches(1.0),
|
|
147
|
-
fill=palette["primary"])
|
|
148
|
-
add_textbox(slide, title,
|
|
149
|
-
left=Inches(0.5), top=Inches(0.22),
|
|
150
|
-
width=Inches(12.3), height=Inches(0.7),
|
|
151
|
-
size=26, bold=True, color="#FFFFFF")
|
|
621
|
+
left=c.x(0.75), top=c.y(3.0),
|
|
622
|
+
width=c.right_margin_w, height=c.y(1.5),
|
|
623
|
+
size=c.pt(48), bold=True, color="#FFFFFF")
|
|
624
|
+
add_rect(slide, left=c.x(0.75), top=c.y(4.6),
|
|
625
|
+
width=c.x(2), height=c.y(0.1), fill=palette["accent"])
|
|
152
626
|
|
|
153
627
|
|
|
154
|
-
def render_content(slide, palette, spec) -> None:
|
|
628
|
+
def render_content(slide, c, palette, spec, ctx) -> None:
|
|
155
629
|
set_background(slide, palette["background"])
|
|
156
|
-
_title_bar(slide, palette, spec.get("title", ""))
|
|
157
|
-
|
|
158
|
-
if bullets:
|
|
159
|
-
add_bullets(slide, bullets,
|
|
160
|
-
left=
|
|
161
|
-
width=
|
|
162
|
-
size=20, color=palette["text"])
|
|
630
|
+
_title_bar(slide, c, palette, spec.get("title", ""))
|
|
631
|
+
region = default_region(c)
|
|
632
|
+
if spec.get("bullets"):
|
|
633
|
+
add_bullets(slide, spec["bullets"],
|
|
634
|
+
left=region.left, top=region.top,
|
|
635
|
+
width=region.width, height=region.height,
|
|
636
|
+
size=c.pt(20), color=palette["text"])
|
|
163
637
|
if spec.get("body"):
|
|
638
|
+
top = region.top + (c.y(0.4) * len(spec.get("bullets") or []) if spec.get("bullets") else 0)
|
|
164
639
|
add_textbox(slide, spec["body"],
|
|
165
|
-
left=
|
|
166
|
-
width=
|
|
167
|
-
size=18, color=palette["text"])
|
|
640
|
+
left=region.left, top=top,
|
|
641
|
+
width=region.width, height=region.height,
|
|
642
|
+
size=c.pt(18), color=palette["text"])
|
|
168
643
|
|
|
169
644
|
|
|
170
|
-
def render_two_column(slide, palette, spec) -> None:
|
|
645
|
+
def render_two_column(slide, c, palette, spec, ctx) -> None:
|
|
171
646
|
set_background(slide, palette["background"])
|
|
172
|
-
_title_bar(slide, palette, spec.get("title", ""))
|
|
173
|
-
|
|
174
|
-
|
|
647
|
+
_title_bar(slide, c, palette, spec.get("title", ""))
|
|
648
|
+
col_w = c.x(6.0)
|
|
649
|
+
for col_key, col_left in (("left", c.x(0.5)), ("right", c.x(6.85))):
|
|
650
|
+
col = spec.get(col_key) or {}
|
|
175
651
|
if col.get("title"):
|
|
176
652
|
add_textbox(slide, col["title"],
|
|
177
|
-
left=col_left, top=
|
|
178
|
-
width=
|
|
179
|
-
size=20, bold=True, color=palette["primary"])
|
|
653
|
+
left=col_left, top=c.y(1.3),
|
|
654
|
+
width=col_w, height=c.y(0.6),
|
|
655
|
+
size=c.pt(20), bold=True, color=palette["primary"])
|
|
180
656
|
if col.get("bullets"):
|
|
181
657
|
add_bullets(slide, col["bullets"],
|
|
182
|
-
left=col_left, top=
|
|
183
|
-
width=
|
|
184
|
-
size=18, color=palette["text"])
|
|
658
|
+
left=col_left, top=c.y(2.0),
|
|
659
|
+
width=col_w, height=c.y(5.0),
|
|
660
|
+
size=c.pt(18), color=palette["text"])
|
|
185
661
|
elif col.get("body"):
|
|
186
662
|
add_textbox(slide, col["body"],
|
|
187
|
-
left=col_left, top=
|
|
188
|
-
width=
|
|
189
|
-
size=18, color=palette["text"])
|
|
663
|
+
left=col_left, top=c.y(2.0),
|
|
664
|
+
width=col_w, height=c.y(5.0),
|
|
665
|
+
size=c.pt(18), color=palette["text"])
|
|
190
666
|
|
|
191
667
|
|
|
192
|
-
def render_big_number(slide, palette, spec) -> None:
|
|
668
|
+
def render_big_number(slide, c, palette, spec, ctx) -> None:
|
|
193
669
|
set_background(slide, palette["background"])
|
|
194
|
-
_title_bar(slide, palette, spec.get("title", ""))
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
670
|
+
_title_bar(slide, c, palette, spec.get("title", ""))
|
|
671
|
+
draw_big_number(slide, c, palette, spec, default_region(c))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def draw_big_number(slide, c, palette, spec, region: Region) -> None:
|
|
675
|
+
number = spec.get("number", "—")
|
|
676
|
+
size = 140 if len(str(number)) <= 6 else (100 if len(str(number)) <= 10 else 72)
|
|
677
|
+
add_textbox(slide, str(number),
|
|
678
|
+
left=region.left, top=region.top,
|
|
679
|
+
width=region.width, height=int(region.height * 0.68),
|
|
680
|
+
size=c.pt(size), bold=True, color=palette["accent"],
|
|
681
|
+
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
200
682
|
if spec.get("caption"):
|
|
201
683
|
add_textbox(slide, spec["caption"],
|
|
202
|
-
left=
|
|
203
|
-
width=
|
|
204
|
-
size=22, color=palette["text"],
|
|
205
|
-
align=PP_ALIGN.CENTER)
|
|
684
|
+
left=region.left, top=region.top + int(region.height * 0.70),
|
|
685
|
+
width=region.width, height=int(region.height * 0.25),
|
|
686
|
+
size=c.pt(22), color=palette["text"], align=PP_ALIGN.CENTER)
|
|
206
687
|
|
|
207
688
|
|
|
208
|
-
def render_quote(slide, palette, spec) -> None:
|
|
689
|
+
def render_quote(slide, c, palette, spec, ctx) -> None:
|
|
209
690
|
set_background(slide, palette["background"])
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
691
|
+
draw_quote(slide, c, palette, spec,
|
|
692
|
+
Region(c.x(1.4), c.y(2.0), c.right_margin_w - c.x(1.3), c.y(4.0)))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def draw_quote(slide, c, palette, spec, region: Region) -> None:
|
|
696
|
+
add_rect(slide, left=region.left, top=region.top,
|
|
697
|
+
width=c.x(0.12), height=region.height * 7 // 8, fill=palette["accent"])
|
|
698
|
+
text_left = region.left + c.x(0.5)
|
|
699
|
+
text_w = region.width - c.x(0.5)
|
|
213
700
|
add_textbox(slide, f"“{spec.get('quote', '')}”",
|
|
214
|
-
left=
|
|
215
|
-
width=
|
|
216
|
-
size=30, color=palette["primary"])
|
|
701
|
+
left=text_left, top=region.top,
|
|
702
|
+
width=text_w, height=int(region.height * 0.72),
|
|
703
|
+
size=c.pt(30), color=palette["primary"])
|
|
217
704
|
if spec.get("attribution"):
|
|
218
705
|
add_textbox(slide, f"— {spec['attribution']}",
|
|
219
|
-
left=
|
|
220
|
-
width=
|
|
221
|
-
size=18, color=palette["text"])
|
|
706
|
+
left=text_left, top=region.top + int(region.height * 0.80),
|
|
707
|
+
width=text_w, height=c.y(0.7),
|
|
708
|
+
size=c.pt(18), color=palette["text"])
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def render_image(slide, c, palette, spec, ctx) -> None:
|
|
712
|
+
set_background(slide, palette["background"])
|
|
713
|
+
position = str(spec.get("position", "full")).lower()
|
|
714
|
+
bullets = spec.get("bullets") or []
|
|
715
|
+
if spec.get("title"):
|
|
716
|
+
_title_bar(slide, c, palette, spec["title"])
|
|
717
|
+
top = c.y(1.4)
|
|
718
|
+
else:
|
|
719
|
+
top = c.y(0.6)
|
|
720
|
+
bottom = c.h - c.y(1.0 if spec.get("caption") else 0.5)
|
|
721
|
+
height = bottom - top
|
|
722
|
+
|
|
723
|
+
if bullets and position in ("left", "right"):
|
|
724
|
+
half = (c.right_margin_w - c.x(0.5)) // 2
|
|
725
|
+
img_left = c.x(0.75) if position == "left" else c.x(0.75) + half + c.x(0.5)
|
|
726
|
+
txt_left = c.x(0.75) + half + c.x(0.5) if position == "left" else c.x(0.75)
|
|
727
|
+
add_bullets(slide, bullets, left=txt_left, top=top + c.y(0.2),
|
|
728
|
+
width=half, height=height, size=c.pt(18), color=palette["text"])
|
|
729
|
+
region = Region(img_left, top, half, height)
|
|
730
|
+
else:
|
|
731
|
+
region = Region(c.x(0.75), top, c.right_margin_w, height)
|
|
732
|
+
|
|
733
|
+
draw_image(slide, c, palette, spec, region, ctx)
|
|
222
734
|
|
|
223
735
|
|
|
224
|
-
def
|
|
736
|
+
def draw_image(slide, c, palette, spec, region: Region, ctx) -> None:
|
|
737
|
+
src = spec.get("image") or spec.get("path")
|
|
738
|
+
if not src:
|
|
739
|
+
warn("image slide has no `image` path; skipped")
|
|
740
|
+
return
|
|
741
|
+
place_image(slide, src, region, spec_dir=ctx["spec_dir"],
|
|
742
|
+
palette=palette, strict=ctx["strict"])
|
|
743
|
+
if spec.get("caption"):
|
|
744
|
+
add_textbox(slide, spec["caption"],
|
|
745
|
+
left=region.left, top=region.top + region.height + c.y(0.05),
|
|
746
|
+
width=region.width, height=c.y(0.4),
|
|
747
|
+
size=c.pt(13), color=palette["secondary"], align=PP_ALIGN.CENTER)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def render_table(slide, c, palette, spec, ctx) -> None:
|
|
751
|
+
set_background(slide, palette["background"])
|
|
752
|
+
_title_bar(slide, c, palette, spec.get("title", ""))
|
|
753
|
+
draw_table(slide, c, palette, spec, default_region(c), ctx)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def draw_table(slide, c, palette, spec, region: Region, ctx) -> None:
|
|
757
|
+
columns = spec.get("columns") or []
|
|
758
|
+
rows = spec.get("rows") or []
|
|
759
|
+
if not columns and not rows:
|
|
760
|
+
warn("table slide has no columns or rows; skipped")
|
|
761
|
+
return
|
|
762
|
+
if len(rows) > 12:
|
|
763
|
+
warn(f"table has {len(rows)} data rows — over 12 stops being readable on a slide")
|
|
764
|
+
height = min(region.height, c.y(0.45) * (len(rows) + 1))
|
|
765
|
+
place_table(slide, columns, rows,
|
|
766
|
+
Region(region.left, region.top, region.width, height), c, palette)
|
|
767
|
+
if spec.get("caption"):
|
|
768
|
+
add_textbox(slide, spec["caption"],
|
|
769
|
+
left=region.left, top=region.top + height + c.y(0.15),
|
|
770
|
+
width=region.width, height=c.y(0.4),
|
|
771
|
+
size=c.pt(13), color=palette["secondary"])
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def render_chart(slide, c, palette, spec, ctx) -> None:
|
|
775
|
+
set_background(slide, palette["background"])
|
|
776
|
+
_title_bar(slide, c, palette, spec.get("title", ""))
|
|
777
|
+
draw_chart(slide, c, palette, spec, default_region(c), ctx)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def draw_chart(slide, c, palette, spec, region: Region, ctx) -> None:
|
|
781
|
+
chart_spec = spec.get("chart") or {}
|
|
782
|
+
takeaway = spec.get("takeaway")
|
|
783
|
+
if takeaway:
|
|
784
|
+
add_textbox(slide, takeaway,
|
|
785
|
+
left=region.left, top=region.top,
|
|
786
|
+
width=region.width, height=c.y(0.45),
|
|
787
|
+
size=c.pt(16), color=palette["text"])
|
|
788
|
+
region = Region(region.left, region.top + c.y(0.55),
|
|
789
|
+
region.width, region.height - c.y(0.55))
|
|
790
|
+
place_chart(slide, chart_spec, region, c, palette)
|
|
791
|
+
if spec.get("source"):
|
|
792
|
+
add_textbox(slide, f"Source: {spec['source']}",
|
|
793
|
+
left=region.left, top=region.top + region.height - c.y(0.05),
|
|
794
|
+
width=region.width, height=c.y(0.35),
|
|
795
|
+
size=c.pt(11), color=palette["secondary"])
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def render_closing(slide, c, palette, spec, ctx) -> None:
|
|
225
799
|
set_background(slide, palette["primary"])
|
|
226
800
|
add_textbox(slide, spec.get("title", "Thank you"),
|
|
227
|
-
left=
|
|
228
|
-
width=
|
|
229
|
-
size=54, bold=True, color="#FFFFFF",
|
|
230
|
-
align=PP_ALIGN.CENTER)
|
|
801
|
+
left=c.x(0.5), top=c.y(2.5),
|
|
802
|
+
width=c.w - c.x(1.0), height=c.y(1.5),
|
|
803
|
+
size=c.pt(54), bold=True, color="#FFFFFF", align=PP_ALIGN.CENTER)
|
|
231
804
|
if spec.get("subtitle"):
|
|
232
805
|
add_textbox(slide, spec["subtitle"],
|
|
233
|
-
left=
|
|
234
|
-
width=
|
|
235
|
-
size=22, color=palette["accent"],
|
|
236
|
-
align=PP_ALIGN.CENTER)
|
|
806
|
+
left=c.x(0.5), top=c.y(4.3),
|
|
807
|
+
width=c.w - c.x(1.0), height=c.y(0.8),
|
|
808
|
+
size=c.pt(22), color=palette["accent"], align=PP_ALIGN.CENTER)
|
|
237
809
|
|
|
238
810
|
|
|
239
811
|
RENDERERS = {
|
|
@@ -243,65 +815,216 @@ RENDERERS = {
|
|
|
243
815
|
"two_column": render_two_column,
|
|
244
816
|
"big_number": render_big_number,
|
|
245
817
|
"quote": render_quote,
|
|
818
|
+
"image": render_image,
|
|
819
|
+
"table": render_table,
|
|
820
|
+
"chart": render_chart,
|
|
246
821
|
"closing": render_closing,
|
|
247
822
|
}
|
|
248
823
|
|
|
824
|
+
# Layouts a template placeholder can express on its own. Everything else keeps
|
|
825
|
+
# the template's chrome but draws its content into the claimed body region.
|
|
826
|
+
PLACEHOLDER_NATIVE = {"title", "section", "content", "two_column", "closing"}
|
|
827
|
+
|
|
828
|
+
CUSTOM_DRAW = {
|
|
829
|
+
"big_number": draw_big_number,
|
|
830
|
+
"quote": draw_quote,
|
|
831
|
+
"image": draw_image,
|
|
832
|
+
"table": draw_table,
|
|
833
|
+
"chart": draw_chart,
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# ------------------------------------------------------------ template mode
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def render_templated(slide, c, palette, spec, ctx, logical: str) -> None:
|
|
841
|
+
"""Fill the template layout's placeholders; draw only what they can't hold."""
|
|
842
|
+
title_phs = placeholders(slide, TITLE_PH)
|
|
843
|
+
title_text = spec.get("title")
|
|
844
|
+
if title_text and title_phs:
|
|
845
|
+
set_ph_text(title_phs[0], title_text)
|
|
846
|
+
elif title_text:
|
|
847
|
+
add_textbox(slide, title_text, left=c.x(0.6), top=c.y(0.4),
|
|
848
|
+
width=c.w - c.x(1.2), height=c.y(0.9),
|
|
849
|
+
size=c.pt(28), bold=True, color=palette["primary"])
|
|
850
|
+
|
|
851
|
+
if logical in CUSTOM_DRAW:
|
|
852
|
+
kinds = BODY_PH + (PP_PLACEHOLDER.PICTURE,) if logical == "image" else BODY_PH
|
|
853
|
+
region = claim_body_region(slide, c, kinds=kinds)
|
|
854
|
+
drawer = CUSTOM_DRAW[logical]
|
|
855
|
+
if logical in ("image", "table", "chart"):
|
|
856
|
+
drawer(slide, c, palette, spec, region, ctx)
|
|
857
|
+
else:
|
|
858
|
+
drawer(slide, c, palette, spec, region)
|
|
859
|
+
drop_empty_placeholders(slide)
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
bodies = [b for b in placeholders(slide, BODY_PH) if b not in title_phs]
|
|
863
|
+
|
|
864
|
+
if logical == "two_column":
|
|
865
|
+
cols = [spec.get("left") or {}, spec.get("right") or {}]
|
|
866
|
+
targets = sorted(bodies, key=lambda s: s.left or 0)[:2]
|
|
867
|
+
if len(targets) >= 2:
|
|
868
|
+
for target, col in zip(targets, cols):
|
|
869
|
+
items = col.get("bullets") or ([col["body"]] if col.get("body") else [])
|
|
870
|
+
if col.get("title"):
|
|
871
|
+
items = [col["title"]] + list(items)
|
|
872
|
+
if items:
|
|
873
|
+
set_ph_bullets(target, items, bold_first=bool(col.get("title")))
|
|
874
|
+
else:
|
|
875
|
+
region = claim_body_region(slide, c)
|
|
876
|
+
half = (region.width - c.x(0.4)) // 2
|
|
877
|
+
for i, col in enumerate(cols):
|
|
878
|
+
left = region.left + i * (half + c.x(0.4))
|
|
879
|
+
if col.get("title"):
|
|
880
|
+
add_textbox(slide, col["title"], left=left, top=region.top,
|
|
881
|
+
width=half, height=c.y(0.5),
|
|
882
|
+
size=c.pt(20), bold=True, color=palette["primary"])
|
|
883
|
+
items = col.get("bullets") or ([col["body"]] if col.get("body") else [])
|
|
884
|
+
if items:
|
|
885
|
+
add_bullets(slide, items, left=left, top=region.top + c.y(0.6),
|
|
886
|
+
width=half, height=region.height - c.y(0.6),
|
|
887
|
+
size=c.pt(18), color=palette["text"])
|
|
888
|
+
drop_empty_placeholders(slide)
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
# Sub-headline: prefer a real subtitle placeholder, then any spare body, then
|
|
892
|
+
# a drawn textbox — a `Title Only` layout must not silently swallow the line.
|
|
893
|
+
if spec.get("subtitle"):
|
|
894
|
+
subs = [b for b in bodies if ph_type(b) == PP_PLACEHOLDER.SUBTITLE]
|
|
895
|
+
target = subs[0] if subs else (bodies[0] if bodies and not spec.get("bullets")
|
|
896
|
+
and not spec.get("body") else None)
|
|
897
|
+
if target is not None:
|
|
898
|
+
set_ph_text(target, spec["subtitle"])
|
|
899
|
+
bodies = [b for b in bodies if b is not target]
|
|
900
|
+
else:
|
|
901
|
+
top = (title_phs[0].top + title_phs[0].height + c.y(0.2)
|
|
902
|
+
if title_phs else c.y(1.4))
|
|
903
|
+
add_textbox(slide, spec["subtitle"], left=c.x(0.75), top=top,
|
|
904
|
+
width=c.right_margin_w, height=c.y(0.8),
|
|
905
|
+
size=c.pt(20), color=palette["text"])
|
|
906
|
+
|
|
907
|
+
payload = spec.get("bullets") or ([spec["body"]] if spec.get("body") else [])
|
|
908
|
+
if payload:
|
|
909
|
+
if bodies:
|
|
910
|
+
set_ph_bullets(bodies[0], payload)
|
|
911
|
+
else:
|
|
912
|
+
region = claim_body_region(slide, c)
|
|
913
|
+
add_bullets(slide, payload, left=region.left, top=region.top,
|
|
914
|
+
width=region.width, height=region.height,
|
|
915
|
+
size=c.pt(18), color=palette["text"])
|
|
916
|
+
|
|
917
|
+
footer = " · ".join([s for s in (spec.get("presenter"), spec.get("date")) if s])
|
|
918
|
+
if footer and logical == "title":
|
|
919
|
+
add_textbox(slide, footer, left=c.x(0.75), top=c.h - c.y(0.9),
|
|
920
|
+
width=c.right_margin_w, height=c.y(0.4),
|
|
921
|
+
size=c.pt(13), color=palette["text"])
|
|
922
|
+
|
|
923
|
+
drop_empty_placeholders(slide)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def layout_numbers_slides(layout) -> bool:
|
|
927
|
+
"""True when the template's own master already stamps a slide number."""
|
|
928
|
+
for shape in layout.placeholders:
|
|
929
|
+
if ph_type(shape) == PP_PLACEHOLDER.SLIDE_NUMBER:
|
|
930
|
+
return True
|
|
931
|
+
return False
|
|
932
|
+
|
|
249
933
|
|
|
250
934
|
# ----------------------------------------------------------------- driver
|
|
251
935
|
|
|
252
936
|
|
|
253
|
-
def build(spec_path: Path, out_path: Path, script_dir: Path) -> None:
|
|
937
|
+
def build(spec_path: Path, out_path: Path, script_dir: Path, *, strict: bool) -> None:
|
|
254
938
|
spec = json.loads(spec_path.read_text())
|
|
939
|
+
spec_dir = spec_path.parent
|
|
255
940
|
palette = resolve_palette(spec.get("palette"), script_dir)
|
|
256
941
|
|
|
257
|
-
prs = Presentation()
|
|
258
|
-
prs.slide_width = SLIDE_W
|
|
259
|
-
prs.slide_height = SLIDE_H
|
|
260
|
-
blank_layout = prs.slide_layouts[6]
|
|
261
|
-
|
|
262
942
|
slides = spec.get("slides", [])
|
|
263
943
|
if not slides:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
944
|
+
die("spec has no slides")
|
|
945
|
+
|
|
946
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
947
|
+
prs, canvas, using_template = open_presentation(spec, spec_dir, Path(tmp))
|
|
948
|
+
layout_map = spec.get("layout_map") or {}
|
|
949
|
+
if using_template and not layout_map:
|
|
950
|
+
warn("template supplied without a `layout_map` — rendering the deck's own "
|
|
951
|
+
"design on the template's blank layout. Map logical layouts to the "
|
|
952
|
+
"template's layout names to inherit its slide designs:\n"
|
|
953
|
+
+ list_layouts(prs))
|
|
954
|
+
|
|
955
|
+
blank = blank_layout(prs)
|
|
956
|
+
show_numbers = spec.get("slide_numbers", len(slides) > 10)
|
|
957
|
+
ctx = {"spec_dir": spec_dir, "strict": strict, "template": using_template}
|
|
958
|
+
|
|
959
|
+
for i, slide_spec in enumerate(slides, start=1):
|
|
960
|
+
logical = slide_spec.get("layout", "content")
|
|
961
|
+
if logical not in RENDERERS:
|
|
962
|
+
warn(f"unknown layout {logical!r} on slide {i}; falling back to content")
|
|
963
|
+
logical = "content"
|
|
964
|
+
|
|
965
|
+
ref = slide_spec.get("layout_name", layout_map.get(logical))
|
|
966
|
+
tmpl_layout = find_layout(prs, ref) if (using_template and ref is not None) else None
|
|
967
|
+
|
|
968
|
+
slide = prs.slides.add_slide(tmpl_layout or blank)
|
|
969
|
+
if tmpl_layout is not None:
|
|
970
|
+
render_templated(slide, canvas, palette, slide_spec, ctx, logical)
|
|
971
|
+
else:
|
|
972
|
+
RENDERERS[logical](slide, canvas, palette, slide_spec, ctx)
|
|
973
|
+
|
|
974
|
+
if slide_spec.get("notes"):
|
|
975
|
+
set_notes(slide, slide_spec["notes"])
|
|
976
|
+
own_numbering = tmpl_layout is not None and layout_numbers_slides(tmpl_layout)
|
|
977
|
+
if (show_numbers and not own_numbering
|
|
978
|
+
and logical not in ("title", "section", "closing")):
|
|
979
|
+
add_slide_number(slide, canvas, i, palette)
|
|
980
|
+
|
|
981
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
982
|
+
prs.save(str(out_path))
|
|
983
|
+
|
|
984
|
+
mode = "template" if using_template else "built-in design"
|
|
985
|
+
print(f"wrote {out_path} ({len(slides)} slides, {mode})")
|
|
986
|
+
if WARNINGS:
|
|
987
|
+
print(f"{len(WARNINGS)} warning(s) — see stderr; fix them before presenting")
|
|
283
988
|
|
|
284
989
|
|
|
285
990
|
def main(argv) -> int:
|
|
286
|
-
ap = argparse.ArgumentParser(description=__doc__,
|
|
287
|
-
|
|
991
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
992
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
993
|
+
ap.add_argument("spec", nargs="?", help="Path to deck spec JSON")
|
|
288
994
|
ap.add_argument("-o", "--output", default=None,
|
|
289
995
|
help="Output .pptx path (default: alongside the spec)")
|
|
996
|
+
ap.add_argument("--strict", action="store_true",
|
|
997
|
+
help="Fail on a missing/unsupported image instead of drawing a placeholder")
|
|
998
|
+
ap.add_argument("--list-layouts", metavar="TEMPLATE",
|
|
999
|
+
help="Print a template's layout names and indices, then exit "
|
|
1000
|
+
"(use them to write layout_map)")
|
|
290
1001
|
args = ap.parse_args(argv)
|
|
291
1002
|
|
|
1003
|
+
if args.list_layouts:
|
|
1004
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1005
|
+
path = Path(args.list_layouts).expanduser().resolve()
|
|
1006
|
+
if not path.exists():
|
|
1007
|
+
die(f"template not found: {path}")
|
|
1008
|
+
shown = path.name
|
|
1009
|
+
if path.suffix.lower() in (".potx", ".potm"):
|
|
1010
|
+
path = _potx_to_pptx(path, Path(tmp))
|
|
1011
|
+
prs = Presentation(str(path))
|
|
1012
|
+
print(f"{shown} — slide size "
|
|
1013
|
+
f"{prs.slide_width / 914400:.2f}in x {prs.slide_height / 914400:.2f}in")
|
|
1014
|
+
print(list_layouts(prs))
|
|
1015
|
+
return 0
|
|
1016
|
+
|
|
1017
|
+
if not args.spec:
|
|
1018
|
+
ap.error("the spec argument is required (or use --list-layouts)")
|
|
1019
|
+
|
|
292
1020
|
spec_path = Path(args.spec).expanduser().resolve()
|
|
293
1021
|
if not spec_path.exists():
|
|
294
|
-
|
|
295
|
-
return 1
|
|
1022
|
+
die(f"spec file not found: {spec_path}")
|
|
296
1023
|
|
|
297
|
-
out_path = (
|
|
298
|
-
|
|
299
|
-
if args.output
|
|
300
|
-
else spec_path.with_suffix(".pptx")
|
|
301
|
-
)
|
|
1024
|
+
out_path = (Path(args.output).expanduser().resolve()
|
|
1025
|
+
if args.output else spec_path.with_suffix(".pptx"))
|
|
302
1026
|
|
|
303
|
-
|
|
304
|
-
build(spec_path, out_path, script_dir)
|
|
1027
|
+
build(spec_path, out_path, Path(__file__).resolve().parent, strict=args.strict)
|
|
305
1028
|
return 0
|
|
306
1029
|
|
|
307
1030
|
|