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.
@@ -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: overall deck title
6
- - audience: archetype (informational; doesn't change rendering)
7
- - palette: either a hex-color block, or a string name resolvable from templates/palettes.json
8
- - slides: list of slide specs, each with a `layout` field
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, closing.
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.shapes import MSO_SHAPE
26
- from pptx.enum.text import PP_ALIGN
27
- from pptx.util import Inches, Pt
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
- SLIDE_W = Inches(13.333)
30
- SLIDE_H = Inches(7.5)
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
- sys.stderr.write(f"warning: palettes file missing at {palettes_path}; using defaults\n")
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
- sys.stderr.write(f"warning: palette {p!r} not found; using defaults\n")
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: int = 18, bold: bool = False, color: str = "#222222",
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 = Pt(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: int = 18, color: str = "#222222") -> None:
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 = Pt(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
- nf = slide.notes_slide.notes_text_frame
106
- nf.text = notes
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=Inches(3.0), width=SLIDE_W, height=Inches(0.15),
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=Inches(0.75), top=Inches(2.0),
118
- width=Inches(11.8), height=Inches(1.2),
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=Inches(0.75), top=Inches(3.4),
123
- width=Inches(11.8), height=Inches(0.8),
124
- size=24, color=palette["text"])
125
- footer_bits = [spec.get("presenter"), spec.get("date")]
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=Inches(0.75), top=Inches(6.5),
130
- width=Inches(11.8), height=Inches(0.5),
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=Inches(0.75), top=Inches(3.0),
138
- width=Inches(11.8), height=Inches(1.5),
139
- size=48, bold=True, color="#FFFFFF")
140
- add_rect(slide, left=Inches(0.75), top=Inches(4.6),
141
- width=Inches(2), height=Inches(0.1),
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
- bullets = spec.get("bullets") or []
158
- if bullets:
159
- add_bullets(slide, bullets,
160
- left=Inches(0.75), top=Inches(1.5),
161
- width=Inches(11.8), height=Inches(5.5),
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=Inches(0.75), top=Inches(1.5),
166
- width=Inches(11.8), height=Inches(5.5),
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
- for col_key, col_left in (("left", Inches(0.5)), ("right", Inches(6.85))):
174
- col = spec.get(col_key, {})
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=Inches(1.3),
178
- width=Inches(6), height=Inches(0.6),
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=Inches(2.0),
183
- width=Inches(6), height=Inches(5),
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=Inches(2.0),
188
- width=Inches(6), height=Inches(5),
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
- add_textbox(slide, spec.get("number", "—"),
196
- left=Inches(0.5), top=Inches(1.8),
197
- width=Inches(12.3), height=Inches(3.5),
198
- size=140, bold=True, color=palette["accent"],
199
- align=PP_ALIGN.CENTER)
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=Inches(0.5), top=Inches(5.6),
203
- width=Inches(12.3), height=Inches(1),
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
- add_rect(slide, left=Inches(1.4), top=Inches(2.0),
211
- width=Inches(0.12), height=Inches(3.5),
212
- fill=palette["accent"])
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=Inches(1.9), top=Inches(2.0),
215
- width=Inches(10), height=Inches(3),
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=Inches(1.9), top=Inches(5.4),
220
- width=Inches(10), height=Inches(0.7),
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 render_closing(slide, palette, spec) -> None:
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=Inches(0.5), top=Inches(2.5),
228
- width=Inches(12.3), height=Inches(1.5),
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=Inches(0.5), top=Inches(4.3),
234
- width=Inches(12.3), height=Inches(0.8),
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
- sys.stderr.write("error: spec has no slides\n")
265
- sys.exit(1)
266
-
267
- for i, slide_spec in enumerate(slides, start=1):
268
- layout = slide_spec.get("layout", "content")
269
- renderer = RENDERERS.get(layout)
270
- if renderer is None:
271
- sys.stderr.write(
272
- f"warning: unknown layout {layout!r} on slide {i}; falling back to content\n"
273
- )
274
- renderer = render_content
275
- slide = prs.slides.add_slide(blank_layout)
276
- renderer(slide, palette, slide_spec)
277
- if slide_spec.get("notes"):
278
- set_notes(slide, slide_spec["notes"])
279
-
280
- out_path.parent.mkdir(parents=True, exist_ok=True)
281
- prs.save(str(out_path))
282
- print(f"wrote {out_path} ({len(slides)} slides)")
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__, formatter_class=argparse.RawDescriptionHelpFormatter)
287
- ap.add_argument("spec", help="Path to deck spec JSON")
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
- sys.stderr.write(f"error: spec file not found: {spec_path}\n")
295
- return 1
1022
+ die(f"spec file not found: {spec_path}")
296
1023
 
297
- out_path = (
298
- Path(args.output).expanduser().resolve()
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
- script_dir = Path(__file__).resolve().parent
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