skilldrop-cli 1.1.2 → 1.2.0

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