skilldrop-cli 1.1.3 → 1.3.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.
- package/README.md +1 -1
- package/catalogue.json +7 -8
- package/model-routing.json +0 -16
- package/package.json +1 -1
- package/packs/ai-engineering/loops/adopt/LOOP.md +120 -0
- package/packs/ai-engineering/loops/adopt/evals/eval_queries.json +10 -0
- package/packs/ai-engineering/loops/adopt/evals/evals.json +69 -0
- package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/ai-ideas.md +15 -0
- package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/org-context.md +27 -0
- package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/staff-survey.csv +11 -0
- package/packs/ai-engineering/loops/adopt/evals/files/adopt-sponsor-slice-blocked-by-client-data/ai-ideas.md +13 -0
- package/packs/ai-engineering/loops/adopt/evals/files/adopt-sponsor-slice-blocked-by-client-data/firm-context.md +23 -0
- package/packs/ai-engineering/loops/adopt/loop.json +53 -0
- package/packs/ai-engineering/loops/ship-agent/LOOP.md +128 -0
- package/packs/ai-engineering/loops/ship-agent/evals/eval_queries.json +10 -0
- package/packs/ai-engineering/loops/ship-agent/evals/evals.json +70 -0
- package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-auto-reply-exfil-open/agent-brief.md +33 -0
- package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-changelog-config-reopens-path/agent-brief.md +33 -0
- package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-changelog-config-reopens-path/claude-settings.json +25 -0
- package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-changelog-config-reopens-path/sample-merged-prs.md +30 -0
- package/packs/ai-engineering/loops/ship-agent/loop.json +53 -0
- package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +1 -1
- package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +2 -2
- package/packs/converters/pack.json +3 -14
- package/packs/converters/skills/file-to-markdown/SKILL.md +28 -11
- package/packs/converters/skills/file-to-markdown/evals/evals.json +30 -0
- package/packs/converters/skills/file-to-markdown/evals/files/5/supplier_agreement_v3.docx +0 -0
- package/packs/converters/skills/file-to-markdown/evals/files/6/numbers.xlsx +0 -0
- package/packs/converters/skills/file-to-markdown/evals/files/6/offsite_q3.pptx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/inputs/acme-brief.docx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/inputs/northwind-roadmap.pptx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/mixed-inputs.md +24 -16
- package/packs/converters/skills/file-to-markdown/manifest.json +2 -3
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +103 -10
- package/packs/converters/skills/md-to-confluence/SKILL.md +2 -2
- package/packs/converters/skills/md-to-confluence/manifest.json +2 -9
- package/packs/converters/skills/md-to-docx/SKILL.md +3 -4
- package/packs/converters/skills/md-to-docx/evals/evals.json +1 -1
- package/packs/converters/skills/md-to-docx/evals/files/1/brand/brand.json +1 -1
- package/packs/converters/skills/md-to-docx/manifest.json +4 -11
- package/packs/converters/skills/md-to-docx/reference.md +1 -1
- package/packs/converters/skills/md-to-docx/scripts/md_to_docx.py +1 -1
- package/packs/converters/skills/md-to-pptx/SKILL.md +3 -5
- package/packs/converters/skills/md-to-pptx/manifest.json +3 -16
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1 -1
- package/packs/converters/skills/md-to-xlsx/SKILL.md +1 -1
- package/packs/converters/skills/md-to-xlsx/manifest.json +2 -2
- package/packs/core/skills/brief-intake/SKILL.md +2 -2
- package/packs/core/skills/brief-intake/manifest.json +2 -2
- package/packs/core/skills/brief-intake/templates/brief-deck.md +3 -3
- package/packs/core/skills/doc-critique/rubrics/deck.md +1 -1
- package/packs/design/pack.json +11 -21
- package/packs/design/skills/brand-kit/SKILL.md +6 -8
- package/packs/design/skills/brand-kit/manifest.json +3 -17
- package/packs/design/skills/brand-kit/templates/brand.json +1 -1
- package/packs/experience-design/loops/clarify/LOOP.md +114 -0
- package/packs/experience-design/loops/clarify/evals/eval_queries.json +10 -0
- package/packs/experience-design/loops/clarify/evals/evals.json +68 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-garden-waste-no-complaint-data/form-fields.csv +12 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-garden-waste-no-complaint-data/garden-waste-page.md +29 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-parking-permit-with-call-data/backoffice-notes.md +14 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-parking-permit-with-call-data/calls-q3.csv +8 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-parking-permit-with-call-data/form-fields.csv +12 -0
- package/packs/experience-design/loops/clarify/evals/files/clarify-parking-permit-with-call-data/permit-page.md +27 -0
- package/packs/experience-design/loops/clarify/loop.json +35 -0
- package/packs/infra-as-code/loops/provision/LOOP.md +128 -0
- package/packs/infra-as-code/loops/provision/evals/eval_queries.json +9 -0
- package/packs/infra-as-code/loops/provision/evals/evals.json +67 -0
- package/packs/infra-as-code/loops/provision/evals/files/provision-rds-no-plan-yet/.terraform.lock.hcl.fixture +24 -0
- package/packs/infra-as-code/loops/provision/evals/files/provision-rds-no-plan-yet/network.md +36 -0
- package/packs/infra-as-code/loops/provision/evals/files/provision-replica-plan-replaces-primary/main.tf +74 -0
- package/packs/infra-as-code/loops/provision/evals/files/provision-replica-plan-replaces-primary/plan.json +901 -0
- package/packs/infra-as-code/loops/provision/evals/files/provision-replica-plan-replaces-primary/variables.tf +47 -0
- package/packs/infra-as-code/loops/provision/loop.json +41 -0
- package/packs/solution-architect/skills/interactive-diagram/SKILL.md +1 -1
- package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -2
- package/packs/stakeholder-comms/skills/audience-profile/SKILL.md +3 -3
- package/packs/stakeholder-comms/skills/audience-profile/manifest.json +2 -2
- package/packs/stakeholder-comms/skills/audience-profile/reference.md +1 -1
- package/packs/converters/skills/md-to-html/SKILL.md +0 -108
- package/packs/converters/skills/md-to-html/evals/eval_queries.json +0 -34
- package/packs/converters/skills/md-to-html/evals/evals.json +0 -44
- package/packs/converters/skills/md-to-html/evals/files/1/brand/brand.json +0 -80
- package/packs/converters/skills/md-to-html/evals/files/1/brand/logo-white.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/brand/logo.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/brand/mark.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/docs/q3-review.md +0 -35
- package/packs/converters/skills/md-to-html/evals/files/1/img/dashboard.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/2/brand.json +0 -13
- package/packs/converters/skills/md-to-html/evals/files/2/img/kick-net.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/2/river-survey-guide.md +0 -53
- package/packs/converters/skills/md-to-html/evals/files/3/nanas-lentil-soup.md +0 -21
- package/packs/converters/skills/md-to-html/evals/files/3/pot.png +0 -0
- package/packs/converters/skills/md-to-html/examples/inputs/chart.svg +0 -8
- package/packs/converters/skills/md-to-html/examples/inputs/ops-review.md +0 -48
- package/packs/converters/skills/md-to-html/examples/ops-review.md +0 -83
- package/packs/converters/skills/md-to-html/manifest.json +0 -43
- package/packs/converters/skills/md-to-html/reference.md +0 -70
- package/packs/converters/skills/md-to-html/scripts/md_to_html.py +0 -662
- package/packs/converters/skills/mermaid-render/SKILL.md +0 -117
- package/packs/converters/skills/mermaid-render/evals/eval_queries.json +0 -30
- package/packs/converters/skills/mermaid-render/evals/evals.json +0 -43
- package/packs/converters/skills/mermaid-render/evals/files/1/docs/contoso-orders.md +0 -23
- package/packs/converters/skills/mermaid-render/evals/files/2/dispensing-diagrams.md +0 -39
- package/packs/converters/skills/mermaid-render/examples/broken-diagrams.md +0 -147
- package/packs/converters/skills/mermaid-render/examples/inputs/broken-flowchart.mmd +0 -8
- package/packs/converters/skills/mermaid-render/examples/inputs/broken-sequence.mmd +0 -11
- package/packs/converters/skills/mermaid-render/examples/inputs/checkout-flow.mmd +0 -9
- package/packs/converters/skills/mermaid-render/examples/inputs/design-notes.md +0 -35
- package/packs/converters/skills/mermaid-render/examples/inputs/fixed-flowchart.mmd +0 -9
- package/packs/converters/skills/mermaid-render/examples/inputs/typo.mmd +0 -2
- package/packs/converters/skills/mermaid-render/manifest.json +0 -50
- package/packs/converters/skills/mermaid-render/reference.md +0 -70
- package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +0 -487
- package/packs/design/skills/deck-builder/SKILL.md +0 -209
- package/packs/design/skills/deck-builder/evals/eval_queries.json +0 -14
- package/packs/design/skills/deck-builder/evals/evals.json +0 -67
- package/packs/design/skills/deck-builder/evals/files/4/ashby-vale-board-notes.md +0 -36
- package/packs/design/skills/deck-builder/examples/exec-board-update.md +0 -108
- package/packs/design/skills/deck-builder/examples/templated-brand-deck.md +0 -148
- package/packs/design/skills/deck-builder/manifest.json +0 -18
- package/packs/design/skills/deck-builder/reference.md +0 -180
- package/packs/design/skills/deck-builder/requirements.txt +0 -3
- package/packs/design/skills/deck-builder/scripts/build_deck.py +0 -1144
- package/packs/design/skills/deck-builder/templates/deck-spec.json +0 -168
- package/packs/design/skills/deck-builder/templates/palettes.json +0 -59
- package/packs/design/skills/marketing-flyer/SKILL.md +0 -113
- package/packs/design/skills/marketing-flyer/evals/eval_queries.json +0 -30
- package/packs/design/skills/marketing-flyer/evals/evals.json +0 -45
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/brand.json +0 -80
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo-white.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/mark.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/2/brand.json +0 -14
- package/packs/design/skills/marketing-flyer/evals/files/2/larchfield-logo.svg +0 -4
- package/packs/design/skills/marketing-flyer/evals/files/2/manager-email.txt +0 -6
- package/packs/design/skills/marketing-flyer/evals/files/2/open-day-brief.md +0 -15
- package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +0 -68
- package/packs/design/skills/marketing-flyer/manifest.json +0 -40
- package/packs/design/skills/marketing-flyer/reference.md +0 -54
- package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +0 -332
- 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:]))
|