pptd-utils 2026.10.2.dev3__py3-none-any.whl

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.
pptd_utils/rich.py ADDED
@@ -0,0 +1,213 @@
1
+ """Rich-text parser: pptd subset of HTML -> paragraph/run model.
2
+
3
+ Also handles: plain-text shorthand (multi-line -> <p> per line), <br/>,
4
+ LaTeX \\(...\\) segments, list styles, span/p/li style attributes.
5
+ """
6
+ import html.parser
7
+ import re
8
+
9
+ from .style import css_px
10
+
11
+ MATH_RE = re.compile(r"\\\((.+?)\\\)", re.S)
12
+ STYLE_RE = re.finditer # alias used inline
13
+
14
+
15
+ def _parse_style(s):
16
+ """'color:#f00; font-size:24px' -> dict."""
17
+ out = {}
18
+ for m in re.finditer(r"([\w-]+)\s*:\s*([^;]+)", s or ""):
19
+ out[m.group(1).strip().lower()] = m.group(2).strip()
20
+ return out
21
+
22
+
23
+ LIST_TYPES = {
24
+ "decimal": "arabicPeriod", "disc": "\u2022", "circle": "\u25E6",
25
+ "square": "\u25AA", "none": None,
26
+ "lower-alpha": "alphaLcPeriod", "upper-alpha": "alphaUcPeriod",
27
+ "lower-roman": "romanLcPeriod", "upper-roman": "romanUcPeriod",
28
+ }
29
+
30
+
31
+ class Run:
32
+ __slots__ = ("text", "span", "bold", "italic", "underline", "strike",
33
+ "sub", "sup", "link", "math")
34
+
35
+ def __init__(self, text, span=None, bold=False, italic=False,
36
+ underline=False, strike=False, sub=False, sup=False,
37
+ link=None, math=None):
38
+ self.text = text
39
+ self.span = span or {}
40
+ self.bold = bold
41
+ self.italic = italic
42
+ self.underline = underline
43
+ self.strike = strike
44
+ self.sub = sub
45
+ self.sup = sup
46
+ self.link = link
47
+ self.math = math
48
+
49
+
50
+ class Para:
51
+ def __init__(self, bullet=False, ordered=False):
52
+ self.bullet = bullet
53
+ self.ordered = ordered
54
+ self.align = None # from p/li style
55
+ self.lh = None # line-height (mult) or px
56
+ self.lh_px = None
57
+ self.mt = None # margin-top px
58
+ self.ml = None # margin-left px
59
+ self.mr = None # margin-right px
60
+ self.ls = None # letter-spacing (li only)
61
+ self.list_style = None # list-style-type key or char
62
+ self.runs = []
63
+ self.br_pending = False
64
+
65
+
66
+ class _Parser(html.parser.HTMLParser):
67
+ def __init__(self):
68
+ super().__init__(convert_charrefs=True)
69
+ self.paras = []
70
+ self.cur = None
71
+ self.spans = []
72
+ self.bold = self.italic = self.underline = self.strike = 0
73
+ self.sub = self.sup = 0
74
+ self.link = None
75
+ self._in_ol = False
76
+
77
+ def new_para(self, bullet=False, ordered=False):
78
+ self.cur = Para(bullet, ordered)
79
+ self.paras.append(self.cur)
80
+ return self.cur
81
+
82
+ def _li_style(self, p, attrs):
83
+ for k, v in attrs.items():
84
+ if k == "text-align":
85
+ p.align = v
86
+ elif k == "line-height":
87
+ if str(v).endswith("px"):
88
+ p.lh_px = css_px(v)
89
+ else:
90
+ p.lh = float(v)
91
+ elif k == "margin-top":
92
+ p.mt = css_px(v)
93
+ elif k == "margin-left":
94
+ p.ml = css_px(v)
95
+ elif k == "margin-right":
96
+ p.mr = css_px(v)
97
+ elif k == "letter-spacing":
98
+ p.ls = css_px(v)
99
+ elif k in ("list-style-type", "list-style"):
100
+ key = v.split()[0] if v else "disc"
101
+ p.list_style = key
102
+
103
+ def handle_starttag(self, tag, attrs):
104
+ a = dict(attrs)
105
+ if tag in ("p", "li"):
106
+ p = self.new_para(bullet=(tag == "li"))
107
+ if tag == "li":
108
+ p.ordered = self._in_ol
109
+ self._li_style(p, _parse_style(a.get("style", "")))
110
+ elif tag in ("ul", "ol"):
111
+ self._in_ol = tag == "ol"
112
+ elif tag == "span":
113
+ self.spans.append(_parse_style(a.get("style", "")))
114
+ elif tag in ("strong", "b"):
115
+ self.bold += 1
116
+ elif tag in ("em", "i"):
117
+ self.italic += 1
118
+ elif tag == "u":
119
+ self.underline += 1
120
+ elif tag == "s":
121
+ self.strike += 1
122
+ elif tag == "sup":
123
+ self.sup += 1
124
+ elif tag == "sub":
125
+ self.sub += 1
126
+ elif tag == "a":
127
+ self.link = a.get("href")
128
+ elif tag == "br":
129
+ if self.cur is not None and self.cur.runs:
130
+ self.cur.runs.append(Run("\n"))
131
+
132
+ def handle_startendtag(self, tag, attrs):
133
+ if tag == "br":
134
+ self.handle_starttag("br", attrs)
135
+ return
136
+ self.handle_starttag(tag, attrs)
137
+ self.handle_endtag(tag)
138
+
139
+ def handle_endtag(self, tag):
140
+ if tag in ("p", "li"):
141
+ self.cur = None
142
+ elif tag in ("ul", "ol"):
143
+ self._in_ol = False
144
+ elif tag == "span":
145
+ if self.spans:
146
+ self.spans.pop()
147
+ elif tag in ("strong", "b"):
148
+ self.bold -= 1
149
+ elif tag in ("em", "i"):
150
+ self.italic -= 1
151
+ elif tag == "u":
152
+ self.underline -= 1
153
+ elif tag == "s":
154
+ self.strike -= 1
155
+ elif tag == "sup":
156
+ self.sup -= 1
157
+ elif tag == "sub":
158
+ self.sub -= 1
159
+ elif tag == "a":
160
+ self.link = None
161
+
162
+ def handle_data(self, data):
163
+ if self.cur is None:
164
+ if not data.strip():
165
+ return
166
+ self.new_para()
167
+ # split LaTeX out of text runs
168
+ pos = 0
169
+ for m in MATH_RE.finditer(data):
170
+ self._emit_text(data[pos:m.start()])
171
+ span = {}
172
+ for st in self.spans:
173
+ span.update(st)
174
+ self.cur.runs.append(Run("", span=span, math=m.group(1).strip()))
175
+ pos = m.end()
176
+ self._emit_text(data[pos:])
177
+
178
+ def _emit_text(self, text):
179
+ if not text:
180
+ return
181
+ text = re.sub(r"\s+", " ", text)
182
+ if not text.strip():
183
+ if not self.cur.runs and text == " ":
184
+ return # leading whitespace before first run
185
+ if not self.cur.runs:
186
+ return
187
+ span = {}
188
+ for st in self.spans:
189
+ span.update(st)
190
+ self.cur.runs.append(Run(
191
+ text, span,
192
+ self.bold > 0, self.italic > 0, self.underline > 0,
193
+ self.strike > 0, self.sub > 0, self.sup > 0, self.link))
194
+
195
+
196
+ def parse_rich(text):
197
+ """Parse pptd rich text -> list[Para]."""
198
+ text = str(text or "")
199
+ if "<" not in text:
200
+ # plain-text shorthand: lines -> paragraphs
201
+ paras = []
202
+ for line in text.split("\n"):
203
+ p = Para()
204
+ if line:
205
+ p.runs.append(Run(re.sub(r"\s+", " ", line)))
206
+ paras.append(p)
207
+ return paras
208
+ p = _Parser()
209
+ p.feed(text)
210
+ p.close()
211
+ if not p.paras:
212
+ p.new_para()
213
+ return p.paras
pptd_utils/shapes.py ADDED
@@ -0,0 +1,334 @@
1
+ """Shape element: 130 OOXML presets + custom SVG-path geometry (custGeom)."""
2
+ import math
3
+ import re
4
+
5
+ from lxml import etree
6
+ from pptx.enum.shapes import MSO_SHAPE
7
+ from pptx.oxml.ns import qn
8
+ from pptx.util import Pt
9
+
10
+ from ._shape_data import SHAPES
11
+ from .style import fill_xml, ln_xml, shadow_xml
12
+ from .xmlutil import sub, insert_ordered, SPPR_ORDER, set_xfrm_flip_rot
13
+
14
+ _PRESSET = {m.xml_value: m for m in MSO_SHAPE}
15
+
16
+
17
+ def apply_preset_geometry(shape, shape_name, adjustments):
18
+ """Set prstGeom preset + avLst adjustments on an autoshape."""
19
+ enum = _PRESSET.get(shape_name)
20
+ if enum is None:
21
+ raise ValueError(f"unknown shapeName {shape_name!r}")
22
+ spPr = shape._element.spPr
23
+ geom = spPr.find(qn("a:prstGeom"))
24
+ geom.set("prst", shape_name)
25
+ avLst = geom.find(qn("a:avLst"))
26
+ for gd in list(avLst):
27
+ avLst.remove(gd)
28
+ defaults = SHAPES.get(shape_name, [])
29
+ values = adjustments if adjustments is not None else defaults
30
+ for i, v in enumerate(values[:len(defaults)] if defaults else values):
31
+ val = int(v)
32
+ gd = sub(avLst, "a:gd", {"name": f"adj{i + 1}", "fmla": f"val {val}"})
33
+
34
+
35
+ # ------------------------------------------------------------- SVG path
36
+
37
+ _NUM = r"[-+]?(?:\d*\.\d+|\d+\.?)"
38
+ _PATH_TOKEN = re.compile(
39
+ r"([MLHVCSQAZmlhvcsqaz])|(" + _NUM + ")")
40
+
41
+
42
+ def _tokens(d):
43
+ cmd = None
44
+ for m in _PATH_TOKEN.finditer(d):
45
+ if m.group(1):
46
+ cmd = m.group(1)
47
+ yield (cmd, None)
48
+ else:
49
+ yield (cmd, float(m.group(0)))
50
+
51
+
52
+ def parse_svg_path(d, vw, vh):
53
+ """SVG path string -> list of custGeom path elements (a:lnTo etc.).
54
+
55
+ Returns list of (tag, values) tuples with absolute coords:
56
+ ("M",[x,y]) ("L",[x,y]) ("C",[x1,y1,x2,y2,x,y]) ("Q",[x1,y1,x,y])
57
+ ("Z",[]) — arcs pre-converted to beziers.
58
+ """
59
+ out = []
60
+ cur = (0.0, 0.0)
61
+ start = (0.0, 0.0)
62
+ last_c2 = None
63
+ last_q = None
64
+ i = 0
65
+ toks = list(_tokens(d))
66
+ n = len(toks)
67
+ rel = False
68
+ cmd = None
69
+ args = []
70
+
71
+ def nxt(k):
72
+ nonlocal i
73
+ vals = []
74
+ for _ in range(k):
75
+ if i >= n:
76
+ raise ValueError("svg path: missing parameters")
77
+ vals.append(toks[i][1])
78
+ i += 1
79
+ return vals
80
+
81
+ def emit(tag, vals):
82
+ out.append((tag, vals))
83
+
84
+ while i < n:
85
+ t = toks[i]
86
+ if t[0] is not None:
87
+ cmd = t[0]
88
+ rel = cmd.islower()
89
+ cmdU = cmd.upper()
90
+ i += 1
91
+ args = []
92
+ if cmdU is None:
93
+ break
94
+ if cmdU == "M":
95
+ x, y = nxt(2)
96
+ if rel:
97
+ x += cur[0]
98
+ y += cur[1]
99
+ emit("M", [x, y])
100
+ cur = (x, y)
101
+ start = cur
102
+ cmd = "l" if rel else "L" # subsequent implicit lineTo
103
+ cmdU = "L"
104
+ elif cmdU == "L":
105
+ x, y = nxt(2)
106
+ if rel:
107
+ x += cur[0]
108
+ y += cur[1]
109
+ emit("L", [x, y])
110
+ cur = (x, y)
111
+ elif cmdU == "H":
112
+ (x,) = nxt(1)
113
+ if rel:
114
+ x += cur[0]
115
+ emit("L", [x, cur[1]])
116
+ cur = (x, cur[1])
117
+ elif cmdU == "V":
118
+ (y,) = nxt(1)
119
+ if rel:
120
+ y += cur[1]
121
+ emit("L", [cur[0], y])
122
+ cur = (cur[0], y)
123
+ elif cmdU in ("C", "S"):
124
+ if cmdU == "C":
125
+ x1, y1, x2, y2, x, y = nxt(6)
126
+ if rel:
127
+ x1 += cur[0]; y1 += cur[1]; x2 += cur[0]; y2 += cur[1]
128
+ x += cur[0]; y += cur[1]
129
+ else: # S: reflect previous control
130
+ x2, y2, x, y = nxt(4)
131
+ if rel:
132
+ x2 += cur[0]; y2 += cur[1]; x += cur[0]; y += cur[1]
133
+ if last_c2 is None:
134
+ x1, y1 = cur
135
+ else:
136
+ x1, y1 = (2 * cur[0] - last_c2[0], 2 * cur[1] - last_c2[1])
137
+ emit("C", [x1, y1, x2, y2, x, y])
138
+ last_c2 = (x2, y2)
139
+ cur = (x, y)
140
+ elif cmdU in ("Q", "T"):
141
+ if cmdU == "Q":
142
+ x1, y1, x, y = nxt(4)
143
+ if rel:
144
+ x1 += cur[0]; y1 += cur[1]; x += cur[0]; y += cur[1]
145
+ else:
146
+ x, y = nxt(2)
147
+ if rel:
148
+ x += cur[0]; y += cur[1]
149
+ if last_q is None:
150
+ x1, y1 = cur
151
+ else:
152
+ x1, y1 = (2 * cur[0] - last_q[0], 2 * cur[1] - last_q[1])
153
+ # promote quadratic to cubic
154
+ cx1 = cur[0] + 2 / 3 * (x1 - cur[0])
155
+ cy1 = cur[1] + 2 / 3 * (y1 - cur[1])
156
+ cx2 = x + 2 / 3 * (x1 - x)
157
+ cy2 = y + 2 / 3 * (y1 - y)
158
+ emit("C", [cx1, cy1, cx2, cy2, x, y])
159
+ last_q = (x1, y1)
160
+ cur = (x, y)
161
+ elif cmdU == "A":
162
+ rx, ry, rot, laf, sf, x, y = nxt(7)
163
+ if rel:
164
+ x += cur[0]
165
+ y += cur[1]
166
+ for seg in _arc_to_cubics(cur[0], cur[1], rx, ry, rot, bool(laf), bool(sf), x, y):
167
+ emit("C", seg)
168
+ cur = (seg[4], seg[5])
169
+ elif cmdU == "Z":
170
+ emit("Z", [])
171
+ cur = start
172
+ else:
173
+ raise ValueError(f"unsupported svg command {cmd!r}")
174
+ return out
175
+
176
+
177
+ def _arc_to_cubics(x0, y0, rx, ry, rot_deg, large, sweep, x1, y1):
178
+ """SVG A -> list of cubic bezier segments (endpoint parameterization)."""
179
+ if rx == 0 or ry == 0:
180
+ return [[x0, y0, x1, y1, x1, y1]]
181
+ phi = math.radians(rot_deg % 360)
182
+ rx, ry = abs(rx), abs(ry)
183
+ cos_p, sin_p = math.cos(phi), math.sin(phi)
184
+ dx2, dy2 = (x0 - x1) / 2, (y0 - y1) / 2
185
+ x1p = cos_p * dx2 + sin_p * dy2
186
+ y1p = -sin_p * dx2 + cos_p * dy2
187
+ lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
188
+ if lam > 1:
189
+ s = math.sqrt(lam)
190
+ rx, ry = rx * s, ry * s
191
+ num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
192
+ den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
193
+ co = math.sqrt(max(0.0, num / den)) if den else 0.0
194
+ if large == sweep:
195
+ co = -co
196
+ cxp = co * rx * y1p / ry
197
+ cyp = -co * ry * x1p / rx
198
+ cx = cos_p * cxp - sin_p * cyp + (x0 + x1) / 2
199
+ cy = sin_p * cxp + cos_p * cyp + (y0 + y1) / 2
200
+
201
+ def ang(ux, uy, vx, vy):
202
+ d = math.hypot(ux, uy) * math.hypot(vx, vy)
203
+ c = max(-1.0, min(1.0, (ux * vx + uy * vy) / d))
204
+ a = math.acos(c)
205
+ if ux * vy - uy * vx < 0:
206
+ a = -a
207
+ return a
208
+
209
+ th1 = ang(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry)
210
+ dth = ang((x1p - cxp) / rx, (y1p - cyp) / ry,
211
+ (-x1p - cxp) / rx, (-y1p - cyp) / ry)
212
+ if not sweep and dth > 0:
213
+ dth -= 2 * math.pi
214
+ elif sweep and dth < 0:
215
+ dth += 2 * math.pi
216
+ segs = []
217
+ nseg = max(1, math.ceil(abs(dth) / (math.pi / 2)))
218
+ delta = dth / nseg
219
+ t = th1
220
+ k = 4 / 3 * math.tan(delta / 4)
221
+ px, py = x0, y0
222
+ for _ in range(nseg):
223
+ t2 = t + delta
224
+ cos_t, sin_t = math.cos(t), math.sin(t)
225
+ cos_t2, sin_t2 = math.cos(t2), math.sin(t2)
226
+ e1x = cx + rx * (cos_t - k * sin_t)
227
+ e1y = cy + ry * (sin_t + k * cos_t)
228
+ e2x = cx + rx * (cos_t2 + k * sin_t2)
229
+ e2y = cy + ry * (sin_t2 - k * cos_t2)
230
+ ex = cx + rx * cos_t2
231
+ ey = cy + ry * sin_t2
232
+ if rot_deg:
233
+ def rot(x, y):
234
+ dx, dy = x - cx, y - cy
235
+ return (cx + cos_p * dx - sin_p * dy,
236
+ cy + sin_p * dx + cos_p * dy)
237
+ e1x, e1y = rot(e1x, e1y)
238
+ e2x, e2y = rot(e2x, e2y)
239
+ ex, ey = rot(ex, ey)
240
+ segs.append([e1x, e1y, e2x, e2y, ex, ey])
241
+ t = t2
242
+ return segs
243
+
244
+
245
+ def build_custgeom(spPr, vw, vh, path_d):
246
+ """Replace spPr geometry with custGeom from an SVG path."""
247
+ for t in ("a:prstGeom", "a:custGeom"):
248
+ e = spPr.find(qn(t))
249
+ if e is not None:
250
+ spPr.remove(e)
251
+ cust = etree.Element(qn("a:custGeom"))
252
+ xfrm = spPr.find(qn("a:xfrm"))
253
+ if xfrm is not None:
254
+ xfrm.addnext(cust)
255
+ else:
256
+ spPr.insert(0, cust)
257
+ sub(cust, "a:avLst")
258
+ sub(cust, "a:gdLst")
259
+ pathLst = sub(cust, "a:pathLst")
260
+ path = sub(pathLst, "a:path", {"w": _i(vw), "h": _i(vh)})
261
+ cur = None
262
+ for tag, vals in parse_svg_path(path_d, vw, vh):
263
+ if tag == "M":
264
+ sub(path, "a:moveTo", {}).append(
265
+ _pt("a:pt", vals[0], vals[1]))
266
+ cur = (vals[0], vals[1])
267
+ elif tag == "L":
268
+ ln = sub(path, "a:lnTo")
269
+ ln.append(_pt("a:pt", vals[0], vals[1]))
270
+ elif tag == "C":
271
+ cb = sub(path, "a:cubicBezTo")
272
+ for j in (0, 2, 4):
273
+ cb.append(_pt("a:pt", vals[j], vals[j + 1]))
274
+ elif tag == "Q":
275
+ qb = sub(path, "a:quadBezTo")
276
+ for j in (0, 2):
277
+ qb.append(_pt("a:pt", vals[j], vals[j + 1]))
278
+ elif tag == "Z":
279
+ sub(path, "a:close")
280
+ return cust
281
+
282
+
283
+ def _i(v):
284
+ return str(int(round(float(v))))
285
+
286
+
287
+ def _pt(tag, x, y):
288
+ e = etree.Element(qn(tag))
289
+ e.set("x", _i(x))
290
+ e.set("y", _i(y))
291
+ return e
292
+
293
+
294
+ # ------------------------------------------------------------- render
295
+
296
+ def render_shape(slide, el, theme):
297
+ name = el.get("shapeName", "rect")
298
+ x, y, w, h = el["bounds"]
299
+ shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Pt(x), Pt(y), Pt(w), Pt(h))
300
+ shape.shadow.inherit = False
301
+ if name == "custom":
302
+ vw, vh = el.get("viewBox", [w, h])
303
+ build_custgeom(shape._element.spPr, vw, vh, el.get("path", ""))
304
+ else:
305
+ apply_preset_geometry(shape, name, el.get("adjustments"))
306
+ spPr = shape._element.spPr
307
+ fill_xml(spPr, el.get("fill"), theme, alpha=1.0,
308
+ image_part_fn=_ipf(slide), stretch=True)
309
+ if el.get("fill") is None:
310
+ for e in spPr.findall(qn("a:noFill")):
311
+ spPr.remove(e)
312
+ insert_ordered(spPr, etree.Element(qn("a:noFill")), SPPR_ORDER)
313
+ ln_xml(spPr, el.get("border"), theme)
314
+ shadow_xml(spPr, el.get("shadow"), theme)
315
+ opacity = el.get("opacity", 1.0)
316
+ if opacity is not None and float(opacity) < 1.0:
317
+ _apply_alpha(spPr, float(opacity))
318
+ set_xfrm_flip_rot(shape, el.get("rotation") or 0, el.get("flip"))
319
+ return shape
320
+
321
+
322
+ def _ipf(slide):
323
+ def fn(path):
324
+ part, rid = slide.part.get_or_add_image_part(str(path))
325
+ return rid
326
+ return fn
327
+
328
+
329
+ def _apply_alpha(spPr, opacity):
330
+ """Inject a:alpha into every srgbClr in spPr (fill + line)."""
331
+ val = str(int(opacity * 100000))
332
+ for srgb in spPr.iter(qn("a:srgbClr")):
333
+ if srgb.find(qn("a:alpha")) is None:
334
+ sub(srgb, "a:alpha", {"val": val})
@@ -0,0 +1,128 @@
1
+ """Slide image export: collage with numbering + single-slide PNGs.
2
+
3
+ Renders via the built .pptx: each wanted slide is isolated into a
4
+ one-slide package and exported by LibreOffice headless (soffice).
5
+ Pillow composes the collage with numbered badges.
6
+
7
+ ponytail: one soffice run per slide (~1.5s each); switch to a single
8
+ PDF export + PyMuPDF rasterize when decks get big.
9
+ """
10
+ import math
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from PIL import Image, ImageDraw, ImageFont
17
+
18
+ from .main import build
19
+ from .charts_raster import _font # system TTF lookup, shared
20
+
21
+
22
+ def _soffice_png(pptx: Path, out_dir: Path) -> Path:
23
+ out_dir.mkdir(parents=True, exist_ok=True)
24
+ subprocess.run(
25
+ ["soffice", "--headless", "--convert-to", "png",
26
+ str(pptx), "--outdir", str(out_dir)],
27
+ check=True, capture_output=True, timeout=120)
28
+ png = out_dir / (pptx.stem + ".png")
29
+ if not png.exists():
30
+ raise RuntimeError(f"soffice did not produce {png}")
31
+ return png
32
+
33
+
34
+ def _split(pptx: Path, index: int, dst: Path) -> Path:
35
+ """index 1-based -> one-slide pptx at dst."""
36
+ from pptx import Presentation
37
+ prs = Presentation(str(pptx))
38
+ lst = prs.slides._sldIdLst
39
+ keep = list(lst)[index - 1]
40
+ for s in list(lst):
41
+ if s is not keep:
42
+ lst.remove(s)
43
+ prs.save(str(dst))
44
+ return dst
45
+
46
+
47
+ def render_slide_images(pptd_path, out_dir, slides=None, tmp_pptx=None):
48
+ """-> [(slide_no, Path)] for the requested 1-based slide numbers."""
49
+ pptd_path = Path(pptd_path)
50
+ out_dir = Path(out_dir)
51
+ if tmp_pptx is None:
52
+ tmp_pptx = Path(tempfile.mkdtemp(prefix="pptd_utils-img-")) / "deck.pptx"
53
+ build(pptd_path, tmp_pptx)
54
+ if slides is None:
55
+ from pptx import Presentation
56
+ slides = range(1, len(Presentation(str(tmp_pptx)).slides._sldIdLst) + 1)
57
+ work = Path(tempfile.mkdtemp(prefix="pptd_utils-split-"))
58
+ results = []
59
+ for n in slides:
60
+ one = _split(tmp_pptx, n, work / f"s{n}.pptx")
61
+ png = _soffice_png(one, work)
62
+ final = out_dir / f"slide_{n:02d}.png"
63
+ out_dir.mkdir(parents=True, exist_ok=True)
64
+ shutil.move(str(png), final)
65
+ results.append((n, final))
66
+ shutil.rmtree(work, ignore_errors=True)
67
+ return results
68
+
69
+
70
+ def render_slide(pptd_path, slide, out_png):
71
+ """Export one slide (1-based) as a PNG."""
72
+ n, png = render_slide_images(pptd_path, Path(out_png).parent, [slide])[0]
73
+ dst = Path(out_png)
74
+ if png != dst:
75
+ shutil.move(str(png), dst)
76
+ return dst
77
+
78
+
79
+ def render_collage(pptd_path, out_png, tile_w=480, gap=20, numbering=True,
80
+ bg="#1F2430", label="Slide", slides=None, tmp_pptx=None):
81
+ """Grid collage of all (or selected) slides with numbered badges."""
82
+ import tempfile
83
+ tmp = Path(tempfile.mkdtemp(prefix="pptd_utils-col-"))
84
+ try:
85
+ imgs = render_slide_images(pptd_path, tmp, slides, tmp_pptx=tmp_pptx)
86
+ tiles = [(n, Image.open(p).convert("RGB")) for n, p in imgs]
87
+ if not tiles:
88
+ raise ValueError("no slides rendered")
89
+ n = len(tiles)
90
+ cols = math.ceil(math.sqrt(n))
91
+ rows = math.ceil(n / cols)
92
+ tw = tile_w
93
+ th = round(tiles[0][1].height * tw / tiles[0][1].width)
94
+ badge = max(18, tw // 18)
95
+ footer = badge + 14 if numbering else 0
96
+ W = cols * tw + (cols + 1) * gap
97
+ H = rows * (th + footer) + (rows + 1) * gap
98
+ sheet = Image.new("RGB", (W, H), _rgb(bg))
99
+ d = ImageDraw.Draw(sheet)
100
+ f = _font(round(badge * 0.62))
101
+ for i, (no, im) in enumerate(tiles):
102
+ r, c = divmod(i, cols)
103
+ x = gap + c * (tw + gap)
104
+ y = gap + r * (th + footer + gap)
105
+ sheet.paste(im.resize((tw, th), Image.LANCZOS), (x, y))
106
+ d.rectangle([x, y, x + tw, y + th], outline=(255, 255, 255),
107
+ width=1)
108
+ if numbering:
109
+ text = f"{label} {no}"
110
+ bb = d.textbbox((0, 0), text, font=f)
111
+ tw_t, th_t = bb[2] - bb[0], bb[3] - bb[1]
112
+ cx = x + (tw - tw_t) / 2 - bb[0]
113
+ cy = y + th + (footer - th_t) / 2 - bb[1]
114
+ d.ellipse([cx - badge * 0.9, cy - badge * 0.45,
115
+ cx + tw_t + badge * 0.9, cy + th_t + badge * 0.45],
116
+ fill=(37, 99, 235))
117
+ d.text((cx, cy), text, font=f, fill=(255, 255, 255))
118
+ out = Path(out_png)
119
+ out.parent.mkdir(parents=True, exist_ok=True)
120
+ sheet.save(out)
121
+ return out
122
+ finally:
123
+ shutil.rmtree(tmp, ignore_errors=True)
124
+
125
+
126
+ def _rgb(hexs):
127
+ h = hexs.lstrip("#")
128
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))