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/README.md +95 -0
- pptd_utils/__init__.py +10 -0
- pptd_utils/__main__.py +5 -0
- pptd_utils/_shape_data.py +134 -0
- pptd_utils/anim.py +367 -0
- pptd_utils/charts_native.py +847 -0
- pptd_utils/charts_raster.py +1019 -0
- pptd_utils/fonts.py +130 -0
- pptd_utils/icons.py +135 -0
- pptd_utils/images.py +105 -0
- pptd_utils/lines.py +180 -0
- pptd_utils/main.py +285 -0
- pptd_utils/render_text.py +241 -0
- pptd_utils/rich.py +213 -0
- pptd_utils/shapes.py +334 -0
- pptd_utils/slides_image.py +128 -0
- pptd_utils/style.py +253 -0
- pptd_utils/tables.py +241 -0
- pptd_utils/xmlutil.py +109 -0
- pptd_utils-2026.10.2.dev3.dist-info/METADATA +120 -0
- pptd_utils-2026.10.2.dev3.dist-info/RECORD +23 -0
- pptd_utils-2026.10.2.dev3.dist-info/WHEEL +4 -0
- pptd_utils-2026.10.2.dev3.dist-info/entry_points.txt +3 -0
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))
|