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/style.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Colors, fills (solid/gradient/image), borders, shadows -> DrawingML XML."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import urllib.request
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lxml import etree
|
|
7
|
+
from pptx.dml.color import RGBColor
|
|
8
|
+
from pptx.oxml.ns import qn
|
|
9
|
+
from pptx.util import Pt
|
|
10
|
+
|
|
11
|
+
from .xmlutil import sub, insert_ordered, SPPR_ORDER, TCPR_ORDER
|
|
12
|
+
|
|
13
|
+
URL_CACHE = Path.home() / ".cache" / "pptd_utils" / "urls"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ----------------------------------------------------------------- colors
|
|
17
|
+
|
|
18
|
+
def resolve_color(c, theme=None, alpha=None):
|
|
19
|
+
"""'$primary' / '#RRGGBB' / '#RRGGBBAA' -> (RGBColor, alpha 0..1)."""
|
|
20
|
+
if c is None:
|
|
21
|
+
return None, 1.0
|
|
22
|
+
c = str(c).strip()
|
|
23
|
+
if c.startswith("$"):
|
|
24
|
+
colors = (theme or {}).get("colors") or {}
|
|
25
|
+
key = c[1:]
|
|
26
|
+
if key not in colors:
|
|
27
|
+
raise KeyError(f"unknown theme color {c!r}")
|
|
28
|
+
c = str(colors[key])
|
|
29
|
+
a = 1.0 if alpha is None else alpha
|
|
30
|
+
if c.startswith("#"):
|
|
31
|
+
hexpart = c[1:]
|
|
32
|
+
if len(hexpart) == 8:
|
|
33
|
+
a *= int(hexpart[6:8], 16) / 255.0
|
|
34
|
+
hexpart = hexpart[:6]
|
|
35
|
+
if len(hexpart) != 6:
|
|
36
|
+
raise ValueError(f"bad color {c!r}")
|
|
37
|
+
return RGBColor.from_string(hexpart.upper()), max(0.0, min(1.0, a))
|
|
38
|
+
raise ValueError(f"unsupported color {c!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def color_el(parent, color, theme, alpha=1.0, tag="a:srgbClr"):
|
|
42
|
+
"""Append a color element (with optional alpha) under parent; return it."""
|
|
43
|
+
rgb, a = resolve_color(color, theme, alpha)
|
|
44
|
+
el = sub(parent, tag, {"val": str(rgb)})
|
|
45
|
+
if a < 1.0:
|
|
46
|
+
sub(el, "a:alpha", {"val": str(int(a * 100000))})
|
|
47
|
+
return el
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hex_to_rgba(color, theme, alpha=1.0):
|
|
51
|
+
"""Pillow helper: '#xxx'/$ref -> (r, g, b, a_int)."""
|
|
52
|
+
rgb, a = resolve_color(color, theme, alpha)
|
|
53
|
+
return (rgb[0], rgb[1], rgb[2], int(a * 255))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def css_px(value):
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
return float(str(value).replace("px", "").replace("PX", "").strip() or 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ----------------------------------------------------------------- fills
|
|
63
|
+
|
|
64
|
+
def fetch_url(url):
|
|
65
|
+
"""Download url to cache, return local Path. jpg/jpeg/png/gif only."""
|
|
66
|
+
if not url.startswith(("http://", "https://")):
|
|
67
|
+
return Path(url)
|
|
68
|
+
ext = ".bin"
|
|
69
|
+
for e in (".jpg", ".jpeg", ".png", ".gif"):
|
|
70
|
+
if e in url.lower():
|
|
71
|
+
ext = e
|
|
72
|
+
break
|
|
73
|
+
h = hashlib.sha1(url.encode()).hexdigest()[:16]
|
|
74
|
+
dst = URL_CACHE / (h + ext)
|
|
75
|
+
if not dst.exists():
|
|
76
|
+
URL_CACHE.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = dst.with_suffix(".part")
|
|
78
|
+
urllib.request.urlretrieve(url, tmp)
|
|
79
|
+
tmp.rename(dst)
|
|
80
|
+
return dst
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def image_size(src):
|
|
84
|
+
from PIL import Image
|
|
85
|
+
with Image.open(src) as im:
|
|
86
|
+
return im.size
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cover_src_rect(src, w, h, crop=None):
|
|
90
|
+
"""Compute a:srcRect attrs (l,t,r,b in 1/1000 %) for 'cover' fit of an
|
|
91
|
+
image into a w x h box, optionally after proportional crop."""
|
|
92
|
+
try:
|
|
93
|
+
iw, ih = image_size(src)
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
if w <= 0 or h <= 0:
|
|
97
|
+
return None
|
|
98
|
+
crop = crop or {}
|
|
99
|
+
l0 = max(0.0, crop.get("left", 0) or 0)
|
|
100
|
+
t0 = max(0.0, crop.get("top", 0) or 0)
|
|
101
|
+
r0 = 1.0 - max(0.0, crop.get("right", 0) or 0)
|
|
102
|
+
b0 = 1.0 - max(0.0, crop.get("bottom", 0) or 0)
|
|
103
|
+
sw, sh = r0 - l0, b0 - t0
|
|
104
|
+
if sw <= 0 or sh <= 0:
|
|
105
|
+
return None
|
|
106
|
+
src_ar, box_ar = (sw * iw) / (sh * ih), w / h
|
|
107
|
+
if src_ar > box_ar: # too wide -> crop sides
|
|
108
|
+
keep = box_ar / src_ar
|
|
109
|
+
cx = (l0 + r0) / 2
|
|
110
|
+
l, r, t, b = cx - keep * sw / 2, cx + keep * sw / 2, t0, b0
|
|
111
|
+
else: # too tall -> crop top/bottom
|
|
112
|
+
keep = src_ar / box_ar
|
|
113
|
+
cy = (t0 + b0) / 2
|
|
114
|
+
t, b, l, r = cy - keep * sh / 2, cy + keep * sh / 2, l0, r0
|
|
115
|
+
out = {}
|
|
116
|
+
if l > 0.0001:
|
|
117
|
+
out["l"] = str(int(round(l * 100000)))
|
|
118
|
+
if t > 0.0001:
|
|
119
|
+
out["t"] = str(int(round(t * 100000)))
|
|
120
|
+
if 1 - r > 0.0001:
|
|
121
|
+
out["r"] = str(int(round((1 - r) * 100000)))
|
|
122
|
+
if 1 - b > 0.0001:
|
|
123
|
+
out["b"] = str(int(round((1 - b) * 100000)))
|
|
124
|
+
return out or None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
FILL_TAGS = ("a:noFill", "a:solidFill", "a:gradFill", "a:blipFill",
|
|
128
|
+
"a:pattFill", "a:grpFill")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _clear_fill(container):
|
|
132
|
+
for t in FILL_TAGS:
|
|
133
|
+
for e in container.findall(qn(t)):
|
|
134
|
+
container.remove(e)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fill_xml(container, fill, theme, alpha=1.0, order=SPPR_ORDER,
|
|
138
|
+
image_part_fn=None, stretch=True, src_rect=None):
|
|
139
|
+
"""Write a Fill spec as XML under container (spPr / tcPr / bgPr / rPr...).
|
|
140
|
+
|
|
141
|
+
image_part_fn(src_path) -> rId, required for image fills.
|
|
142
|
+
stretch=True uses a:stretch; False (tile) unsupported -> stretch.
|
|
143
|
+
Returns True if a fill element was written, False if fill is None.
|
|
144
|
+
"""
|
|
145
|
+
if fill is None:
|
|
146
|
+
return False
|
|
147
|
+
ftype = fill.get("type", "solid")
|
|
148
|
+
_clear_fill(container)
|
|
149
|
+
fill_alpha = alpha * float(fill.get("opacity", 1.0))
|
|
150
|
+
if ftype == "solid":
|
|
151
|
+
sf = etree.SubElement(container, qn("a:solidFill"))
|
|
152
|
+
insert_ordered(container, sf, order)
|
|
153
|
+
color_el(sf, fill.get("color", "#000000"), theme, fill_alpha)
|
|
154
|
+
return True
|
|
155
|
+
if ftype == "gradient":
|
|
156
|
+
gf = sub(container, "a:gradFill", {"rotWithShape": "1"})
|
|
157
|
+
insert_ordered(container, gf, order)
|
|
158
|
+
sub(gf, "a:gsLst")
|
|
159
|
+
gslst = gf.find(qn("a:gsLst"))
|
|
160
|
+
for stop in fill.get("stops", []):
|
|
161
|
+
gs = sub(gslst, "a:gs", {"pos": str(int(stop.get("position", 0) * 100000))})
|
|
162
|
+
color_el(gs, stop.get("color", "#000000"), theme,
|
|
163
|
+
fill_alpha * float(stop.get("opacity", 1.0)))
|
|
164
|
+
gt = fill.get("gradientType", "linear")
|
|
165
|
+
if gt == "radial":
|
|
166
|
+
path = sub(gf, "a:path", {"path": "circle"})
|
|
167
|
+
sub(path, "a:fillToRect", {"l": "50000", "t": "50000", "r": "50000", "b": "50000"})
|
|
168
|
+
else:
|
|
169
|
+
sub(gf, "a:lin", {"ang": str(int(fill.get("angle", 0) * 60000)), "scaled": "1"})
|
|
170
|
+
return True
|
|
171
|
+
if ftype == "image":
|
|
172
|
+
if image_part_fn is None:
|
|
173
|
+
return False
|
|
174
|
+
src = fill.get("src")
|
|
175
|
+
path = fetch_url(src) if str(src).startswith("http") else Path(src)
|
|
176
|
+
rid = image_part_fn(path)
|
|
177
|
+
bf = sub(container, "a:blipFill")
|
|
178
|
+
insert_ordered(container, bf, order)
|
|
179
|
+
blip = sub(bf, "a:blip")
|
|
180
|
+
blip.set(qn("r:embed"), rid)
|
|
181
|
+
opacity = fill.get("opacity", 1.0)
|
|
182
|
+
if opacity is not None and float(opacity) < 1.0:
|
|
183
|
+
sub(blip, "a:alphaModFix", {"amt": str(int(float(opacity) * 100000))})
|
|
184
|
+
rect = src_rect
|
|
185
|
+
if rect is None and fill.get("fit", {}).get("mode", "cover") == "cover":
|
|
186
|
+
rect = cover_src_rect(path, 100, 100)
|
|
187
|
+
if rect:
|
|
188
|
+
sub(bf, "a:srcRect", rect)
|
|
189
|
+
if stretch:
|
|
190
|
+
st = sub(bf, "a:stretch")
|
|
191
|
+
sub(st, "a:fillRect")
|
|
192
|
+
return True
|
|
193
|
+
raise ValueError(f"unsupported fill type {ftype!r}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ----------------------------------------------------------------- border
|
|
197
|
+
|
|
198
|
+
def line_style_attrs(border):
|
|
199
|
+
return {"solid": "solid", "dash": "dash", "dot": "sysDot"}.get(
|
|
200
|
+
border.get("style", "solid"), "solid")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ln_xml(container, border, theme, alpha=1.0, order=SPPR_ORDER):
|
|
204
|
+
"""Write a:ln from Border spec; border None -> clear line (a:ln noFill)."""
|
|
205
|
+
from pptx.enum.dml import MSO_LINE
|
|
206
|
+
dash = {"solid": None, "dash": MSO_LINE.DASH, "dot": MSO_LINE.ROUND_DOT}
|
|
207
|
+
if border is None:
|
|
208
|
+
for e in container.findall(qn("a:ln")):
|
|
209
|
+
container.remove(e)
|
|
210
|
+
ln = sub(container, "a:ln", {"w": str(int(Pt(border and border.get("width", 1) or 1)))})
|
|
211
|
+
insert_ordered(container, ln, order)
|
|
212
|
+
sub(ln, "a:noFill")
|
|
213
|
+
return ln
|
|
214
|
+
for e in container.findall(qn("a:ln")):
|
|
215
|
+
container.remove(e)
|
|
216
|
+
w = border.get("width", 1)
|
|
217
|
+
ln = sub(container, "a:ln", {"w": str(int(Pt(w)))})
|
|
218
|
+
insert_ordered(container, ln, order)
|
|
219
|
+
sf = sub(ln, "a:solidFill")
|
|
220
|
+
color_el(sf, border.get("color", "#000000"), theme, alpha)
|
|
221
|
+
if border.get("style") in ("dash", "dot"):
|
|
222
|
+
sub(ln, "a:prstDash", {"val": line_style_attrs(border)})
|
|
223
|
+
sub(ln, "a:round")
|
|
224
|
+
return ln
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ----------------------------------------------------------------- shadow
|
|
228
|
+
|
|
229
|
+
def shadow_xml(container, shadow, theme, order=SPPR_ORDER):
|
|
230
|
+
"""Write a:effectLst/a:outerShdw; shadow None -> remove effectLst."""
|
|
231
|
+
if shadow is None:
|
|
232
|
+
for e in container.findall(qn("a:effectLst")):
|
|
233
|
+
container.remove(e)
|
|
234
|
+
return
|
|
235
|
+
for e in container.findall(qn("a:effectLst")):
|
|
236
|
+
container.remove(e)
|
|
237
|
+
el = sub(container, "a:effectLst")
|
|
238
|
+
insert_ordered(container, el, order)
|
|
239
|
+
shdw = sub(el, "a:outerShdw", {
|
|
240
|
+
"blurRad": str(int(Pt(shadow.get("blur", 4)))),
|
|
241
|
+
"dist": str(int(Pt(0))),
|
|
242
|
+
"dir": "2700000",
|
|
243
|
+
"rotWithShape": "0",
|
|
244
|
+
})
|
|
245
|
+
off = shadow.get("offset") or [0, 0]
|
|
246
|
+
dx, dy = float(off[0]), float(off[1])
|
|
247
|
+
import math
|
|
248
|
+
if dx or dy:
|
|
249
|
+
dist = math.hypot(dx, dy)
|
|
250
|
+
ang = math.degrees(math.atan2(dy, dx)) % 360
|
|
251
|
+
shdw.set("dist", str(int(Pt(dist))))
|
|
252
|
+
shdw.set("dir", str(int(ang * 60000)))
|
|
253
|
+
color_el(shdw, shadow.get("color", "#000000"), theme)
|
pptd_utils/tables.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Table element: merges, full style-inheritance chain, per-side borders."""
|
|
2
|
+
from lxml import etree
|
|
3
|
+
from pptx.oxml.ns import qn
|
|
4
|
+
from pptx.util import Pt
|
|
5
|
+
|
|
6
|
+
from .render_text import emit_text, TEXTSTYLE_KEYS
|
|
7
|
+
from .style import fill_xml, color_el, line_style_attrs
|
|
8
|
+
from .xmlutil import sub, insert_ordered, TCPR_ORDER
|
|
9
|
+
|
|
10
|
+
CELL_DEFAULTS = {
|
|
11
|
+
"color": "#000000", "fontSize": 14, "fontFamily": "MiSans",
|
|
12
|
+
"bold": False, "italic": False, "lineHeight": 1,
|
|
13
|
+
"letterSpacing": 0, "marginTop": 0,
|
|
14
|
+
"fill": None, "border": None, "align": ["center", "middle"],
|
|
15
|
+
}
|
|
16
|
+
TEXT_KEYS = ("color", "fontSize", "fontFamily", "bold", "italic",
|
|
17
|
+
"backgroundColor", "lineHeight", "lineHeightPx",
|
|
18
|
+
"letterSpacing", "marginTop")
|
|
19
|
+
LAYOUT_KEYS = ("fill", "border", "align")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm_border_spec(spec):
|
|
23
|
+
"""BorderSpec -> {side: Border|None} for t/r/b/l; None = unspecified."""
|
|
24
|
+
if spec is None:
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(spec, dict):
|
|
27
|
+
b = {k: spec.get(k) for k in ("style", "width", "color")}
|
|
28
|
+
return {"t": b, "r": b, "b": b, "l": b}
|
|
29
|
+
if isinstance(spec, list):
|
|
30
|
+
if len(spec) == 2:
|
|
31
|
+
tb, lr = spec
|
|
32
|
+
return {"t": tb, "b": tb, "l": lr, "r": lr}
|
|
33
|
+
if len(spec) == 4:
|
|
34
|
+
t, r, b, l = spec
|
|
35
|
+
return {"t": t, "r": r, "b": b, "l": l}
|
|
36
|
+
raise ValueError(f"bad BorderSpec {spec!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge(dst, src, keys):
|
|
40
|
+
for k in keys:
|
|
41
|
+
if src and src.get(k) is not None:
|
|
42
|
+
dst[k] = src[k]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_cell_style(r, c, nrows, ncols, cfg, cell, table_fill):
|
|
46
|
+
"""Full pptd table style chain -> flat dict + per-side borders."""
|
|
47
|
+
out = dict(CELL_DEFAULTS)
|
|
48
|
+
_merge(out, cfg.get("cellStyle"), TEXT_KEYS + LAYOUT_KEYS)
|
|
49
|
+
# bodyStyles: data rows (not first/last), cycled by data-row index
|
|
50
|
+
body = cfg.get("bodyStyles")
|
|
51
|
+
if body and r not in (0, nrows - 1):
|
|
52
|
+
di = r - 1 if nrows > 2 else r
|
|
53
|
+
bs = body[di % len(body)]
|
|
54
|
+
_merge(out, bs, TEXT_KEYS + LAYOUT_KEYS)
|
|
55
|
+
# category styles
|
|
56
|
+
row_style = None
|
|
57
|
+
if r == 0 and cfg.get("firstRowStyle") is not None:
|
|
58
|
+
row_style = cfg["firstRowStyle"]
|
|
59
|
+
elif r == nrows - 1 and nrows > 1 and cfg.get("lastRowStyle") is not None:
|
|
60
|
+
row_style = cfg["lastRowStyle"]
|
|
61
|
+
col_style = None
|
|
62
|
+
if c == 0 and cfg.get("firstColumnStyle") is not None:
|
|
63
|
+
col_style = cfg["firstColumnStyle"]
|
|
64
|
+
elif c == ncols - 1 and ncols > 1 and cfg.get("lastColumnStyle") is not None:
|
|
65
|
+
col_style = cfg["lastColumnStyle"]
|
|
66
|
+
row_wins = cfg.get("rowOverColumn", True)
|
|
67
|
+
loser, winner = (col_style, row_style) if row_wins else (row_style, col_style)
|
|
68
|
+
_merge(out, loser, TEXT_KEYS + LAYOUT_KEYS)
|
|
69
|
+
_merge(out, winner, TEXT_KEYS + LAYOUT_KEYS)
|
|
70
|
+
# 5. cell.textStyle theme ref (text fields only)
|
|
71
|
+
if cell.get("textStyle"):
|
|
72
|
+
ref = cell["_theme_textstyles"].get(str(cell["textStyle"]).lstrip("$"), {})
|
|
73
|
+
_merge(out, ref, TEXT_KEYS)
|
|
74
|
+
# 4. cell inline
|
|
75
|
+
_merge(out, cell, TEXT_KEYS + LAYOUT_KEYS)
|
|
76
|
+
# table-level fill under everything
|
|
77
|
+
if out.get("fill") is None and table_fill is not None:
|
|
78
|
+
out["fill"] = table_fill
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
UNSET = object() # distinguishes 'field absent' from 'explicit null' (clear)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _border_sides(r, c, nrows, ncols, cfg, cell):
|
|
86
|
+
"""Resolve per-side Border|None from the chain (top-down per side).
|
|
87
|
+
Chain: cell.border > row/col category (rowOverColumn) > bodyStyles >
|
|
88
|
+
cellStyle.border. Explicit null clears all sides at that level."""
|
|
89
|
+
specs = [cell.get("border", UNSET)]
|
|
90
|
+
row_style = (cfg.get("firstRowStyle") if r == 0 else
|
|
91
|
+
cfg.get("lastRowStyle") if r == nrows - 1 else None)
|
|
92
|
+
col_style = (cfg.get("firstColumnStyle") if c == 0 else
|
|
93
|
+
cfg.get("lastColumnStyle") if c == ncols - 1 else None)
|
|
94
|
+
row_wins = cfg.get("rowOverColumn", True)
|
|
95
|
+
loser, winner = (col_style, row_style) if row_wins else (row_style, col_style)
|
|
96
|
+
if loser is not None:
|
|
97
|
+
specs.append(loser.get("border", UNSET))
|
|
98
|
+
if winner is not None:
|
|
99
|
+
specs.append(winner.get("border", UNSET))
|
|
100
|
+
body = cfg.get("bodyStyles")
|
|
101
|
+
if body and r not in (0, nrows - 1):
|
|
102
|
+
di = r - 1 if nrows > 2 else r
|
|
103
|
+
specs.append(body[di % len(body)].get("border", UNSET))
|
|
104
|
+
if cfg.get("cellStyle") is not None:
|
|
105
|
+
specs.append(cfg["cellStyle"].get("border", UNSET))
|
|
106
|
+
|
|
107
|
+
resolved = {}
|
|
108
|
+
for spec in specs:
|
|
109
|
+
if spec is UNSET:
|
|
110
|
+
continue
|
|
111
|
+
if spec is None: # explicit clear-all
|
|
112
|
+
for side in ("t", "r", "b", "l"):
|
|
113
|
+
resolved.setdefault(side, None)
|
|
114
|
+
continue
|
|
115
|
+
norm = _norm_border_spec(spec)
|
|
116
|
+
for side in ("t", "r", "b", "l"):
|
|
117
|
+
if side not in resolved:
|
|
118
|
+
resolved[side] = norm.get(side)
|
|
119
|
+
default = {"style": "solid", "width": 1, "color": "#000000"}
|
|
120
|
+
for side in ("t", "r", "b", "l"):
|
|
121
|
+
if side not in resolved:
|
|
122
|
+
resolved[side] = default
|
|
123
|
+
return resolved
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_table(slide, el, theme, root):
|
|
127
|
+
ncols = len(el["columnWidths"])
|
|
128
|
+
rows = el["rows"]
|
|
129
|
+
grid = {}
|
|
130
|
+
occupied = set()
|
|
131
|
+
r = 0
|
|
132
|
+
max_row = 0
|
|
133
|
+
for row in rows:
|
|
134
|
+
c = 0
|
|
135
|
+
for cell in row:
|
|
136
|
+
while (r, c) in occupied:
|
|
137
|
+
c += 1
|
|
138
|
+
rs = int(cell.get("rowSpan", 1) or 1)
|
|
139
|
+
cs = int(cell.get("colSpan", 1) or 1)
|
|
140
|
+
grid[(r, c)] = cell
|
|
141
|
+
for dr in range(rs):
|
|
142
|
+
for dc in range(cs):
|
|
143
|
+
if dr or dc:
|
|
144
|
+
occupied.add((r + dr, c + dc))
|
|
145
|
+
max_row = max(max_row, r + rs)
|
|
146
|
+
c += cs
|
|
147
|
+
r += 1
|
|
148
|
+
nrows = max(r, max_row)
|
|
149
|
+
|
|
150
|
+
x, y, w, h = el["bounds"]
|
|
151
|
+
tbl_shape = slide.shapes.add_table(nrows, ncols, Pt(x), Pt(y), Pt(w), Pt(h))
|
|
152
|
+
table = tbl_shape.table
|
|
153
|
+
# explicit grid geometry from ratios
|
|
154
|
+
for i, ratio in enumerate(el["columnWidths"]):
|
|
155
|
+
table.columns[i].width = Pt(w * ratio)
|
|
156
|
+
for i, ratio in enumerate(el["rowHeights"][:nrows]):
|
|
157
|
+
table.rows[i].height = Pt(h * ratio)
|
|
158
|
+
if len(el["rowHeights"]) < nrows: # rows beyond provided ratios: equal split
|
|
159
|
+
rest = nrows - len(el["rowHeights"])
|
|
160
|
+
per = h / max(1, nrows)
|
|
161
|
+
for i in range(len(el["rowHeights"]), nrows):
|
|
162
|
+
table.rows[i].height = Pt(per)
|
|
163
|
+
# strip default banded style
|
|
164
|
+
tbl = table._tbl
|
|
165
|
+
tblPr = tbl.find(qn("a:tblPr"))
|
|
166
|
+
if tblPr is not None:
|
|
167
|
+
for a in ("firstRow", "lastRow", "firstCol", "lastCol", "bandRow", "bandCol"):
|
|
168
|
+
tblPr.set(a, "0")
|
|
169
|
+
for sid in tblPr.findall(qn("a:tableStyleId")):
|
|
170
|
+
tblPr.remove(sid)
|
|
171
|
+
|
|
172
|
+
# resolve TableStyleConfig
|
|
173
|
+
style = el.get("style")
|
|
174
|
+
cfg = {}
|
|
175
|
+
if style:
|
|
176
|
+
if isinstance(style, str):
|
|
177
|
+
cfg = (theme.get("tableStyles") or {}).get(style.lstrip("$"), {})
|
|
178
|
+
else:
|
|
179
|
+
cfg = style
|
|
180
|
+
table_fill = el.get("fill")
|
|
181
|
+
|
|
182
|
+
# apply merges first
|
|
183
|
+
for (rr, cc), cell in grid.items():
|
|
184
|
+
rs = int(cell.get("rowSpan", 1) or 1)
|
|
185
|
+
cs = int(cell.get("colSpan", 1) or 1)
|
|
186
|
+
if rs > 1 or cs > 1:
|
|
187
|
+
origin = table.cell(rr, cc)
|
|
188
|
+
end = table.cell(rr + rs - 1, cc + cs - 1)
|
|
189
|
+
origin.merge(end)
|
|
190
|
+
|
|
191
|
+
for (rr, cc), cell in sorted(grid.items()):
|
|
192
|
+
table_cell = table.cell(rr, cc)
|
|
193
|
+
cell_env = dict(cell)
|
|
194
|
+
cell_env["_theme_textstyles"] = theme.get("textStyles") or {}
|
|
195
|
+
flat = resolve_cell_style(rr, cc, nrows, ncols, cfg, cell_env, table_fill)
|
|
196
|
+
sides = _border_sides(rr, cc, nrows, ncols, cfg, cell)
|
|
197
|
+
tcPr = table_cell._tc.get_or_add_tcPr()
|
|
198
|
+
# fill
|
|
199
|
+
fill_xml(tcPr, flat.get("fill"), theme, order=TCPR_ORDER,
|
|
200
|
+
image_part_fn=_ipf(slide))
|
|
201
|
+
# borders: lnL lnR lnT lnB
|
|
202
|
+
for side, tag in (("l", "a:lnL"), ("r", "a:lnR"), ("t", "a:lnT"), ("b", "a:lnB")):
|
|
203
|
+
for e in tcPr.findall(qn(tag)):
|
|
204
|
+
tcPr.remove(e)
|
|
205
|
+
b = sides.get(side)
|
|
206
|
+
if not b:
|
|
207
|
+
continue
|
|
208
|
+
ln = etree.Element(qn(tag))
|
|
209
|
+
ln.set("w", str(int(Pt(b.get("width", 1)))))
|
|
210
|
+
ln.set("cap", "flat")
|
|
211
|
+
ln.set("cmpd", "sng")
|
|
212
|
+
ln.set("algn", "ctr")
|
|
213
|
+
sf = sub(ln, "a:solidFill")
|
|
214
|
+
color_el(sf, b.get("color", "#000000"), theme)
|
|
215
|
+
if b.get("style") in ("dash", "dot"):
|
|
216
|
+
sub(ln, "a:prstDash", {"val": line_style_attrs(b)})
|
|
217
|
+
insert_ordered(tcPr, ln, TCPR_ORDER)
|
|
218
|
+
# text
|
|
219
|
+
align = flat.get("align") or ["center", "middle"]
|
|
220
|
+
tf = table_cell.text_frame
|
|
221
|
+
tf.word_wrap = True
|
|
222
|
+
from pptx.enum.text import MSO_ANCHOR
|
|
223
|
+
tf.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE,
|
|
224
|
+
"bottom": MSO_ANCHOR.BOTTOM}.get(align[1], MSO_ANCHOR.MIDDLE)
|
|
225
|
+
fs = flat.get("fontSize")
|
|
226
|
+
if fs is None:
|
|
227
|
+
rh = h * el["rowHeights"][rr] if rr < len(el["rowHeights"]) else h / nrows
|
|
228
|
+
fs = max(10, min(18, rh * 0.45))
|
|
229
|
+
content = {k: v for k, v in flat.items() if k in TEXT_KEYS}
|
|
230
|
+
content["text"] = cell.get("text", "")
|
|
231
|
+
content.setdefault("fontSize", fs)
|
|
232
|
+
content["align"] = align
|
|
233
|
+
emit_text(tf, content, theme, default_align=align)
|
|
234
|
+
return tbl_shape
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _ipf(slide):
|
|
238
|
+
def fn(path):
|
|
239
|
+
part, rid = slide.part.get_or_add_image_part(str(path))
|
|
240
|
+
return rid
|
|
241
|
+
return fn
|
pptd_utils/xmlutil.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Low-level XML helpers: element creation in schema order, namespaces."""
|
|
2
|
+
from lxml import etree
|
|
3
|
+
from pptx.oxml.ns import qn as _qn
|
|
4
|
+
|
|
5
|
+
NSMAP = {
|
|
6
|
+
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
7
|
+
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
8
|
+
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
9
|
+
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
|
10
|
+
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
|
|
11
|
+
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def qn(tag):
|
|
16
|
+
return _qn(tag)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def E(tag, attrs=None, text=None):
|
|
20
|
+
"""Create element 'a:gradFill' style. attrs values: str|number."""
|
|
21
|
+
e = etree.SubElement # placeholder
|
|
22
|
+
root = etree.Element(qn(tag), nsmap=None)
|
|
23
|
+
if attrs:
|
|
24
|
+
for k, v in attrs.items():
|
|
25
|
+
if v is not None:
|
|
26
|
+
root.set(k, str(v))
|
|
27
|
+
if text is not None:
|
|
28
|
+
root.text = str(text)
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sub(parent, tag, attrs=None, text=None):
|
|
33
|
+
e = etree.SubElement(parent, qn(tag), nsmap=None)
|
|
34
|
+
if attrs:
|
|
35
|
+
for k, v in attrs.items():
|
|
36
|
+
if v is not None:
|
|
37
|
+
e.set(k, str(v))
|
|
38
|
+
if text is not None:
|
|
39
|
+
e.text = str(text)
|
|
40
|
+
return e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def insert_ordered(parent, child, order):
|
|
44
|
+
"""Insert child into parent before the first existing element whose tag
|
|
45
|
+
comes after child's tag in `order` (list of qn-style tags)."""
|
|
46
|
+
try:
|
|
47
|
+
idx = order.index(child.tag)
|
|
48
|
+
except ValueError:
|
|
49
|
+
parent.append(child)
|
|
50
|
+
return child
|
|
51
|
+
for existing in parent:
|
|
52
|
+
try:
|
|
53
|
+
eidx = order.index(existing.tag)
|
|
54
|
+
except ValueError:
|
|
55
|
+
continue
|
|
56
|
+
if eidx > idx:
|
|
57
|
+
existing.addprevious(child)
|
|
58
|
+
return child
|
|
59
|
+
parent.append(child)
|
|
60
|
+
return child
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Schema child orders (subset used by this package)
|
|
64
|
+
RPR_ORDER = [qn(t) for t in (
|
|
65
|
+
"a:ln", "a:noFill", "a:solidFill", "a:gradFill", "a:blipFill", "a:pattFill",
|
|
66
|
+
"a:grpFill", "a:effectLst", "a:effectDag", "a:highlight", "a:uLnTx", "a:uLn",
|
|
67
|
+
"a:uFillTx", "a:uFill", "a:latin", "a:ea", "a:cs", "a:sym", "a:hlinkClick",
|
|
68
|
+
"a:hlinkMouseOver", "a:rtl", "a:extLst")]
|
|
69
|
+
|
|
70
|
+
PPR_ORDER = [qn(t) for t in (
|
|
71
|
+
"a:lnSpc", "a:spcBef", "a:spcAft", "a:buClrTx", "a:buClr", "a:buSzTx",
|
|
72
|
+
"a:buSzPct", "a:buSzPts", "a:buFontTx", "a:buFont", "a:buNone",
|
|
73
|
+
"a:buAutoNum", "a:buChar", "a:buBlip", "a:tabLst", "a:defRPr", "a:extLst")]
|
|
74
|
+
|
|
75
|
+
TCPR_ORDER = [qn(t) for t in (
|
|
76
|
+
"a:lnL", "a:lnR", "a:lnT", "a:lnB", "a:lnTlToBr", "a:lnBlToTr", "a:cell3D",
|
|
77
|
+
"a:noFill", "a:solidFill", "a:gradFill", "a:blipFill", "a:pattFill",
|
|
78
|
+
"a:grpFill", "a:headers", "a:extLst")]
|
|
79
|
+
|
|
80
|
+
SPPR_ORDER = [qn(t) for t in (
|
|
81
|
+
"a:xfrm", "a:custGeom", "a:prstGeom", "a:noFill", "a:solidFill",
|
|
82
|
+
"a:gradFill", "a:blipFill", "a:pattFill", "a:grpFill", "a:ln",
|
|
83
|
+
"a:effectLst", "a:effectDag", "a:scene3d", "a:sp3d", "a:extLst")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_xml(xml_str):
|
|
87
|
+
"""Parse an XML fragment with our namespaces bound (a/p/c/r/m)."""
|
|
88
|
+
wrapper = ('<root xmlns:a="%(a)s" xmlns:p="%(p)s" xmlns:r="%(r)s" '
|
|
89
|
+
'xmlns:c="%(c)s" xmlns:m="%(m)s">%%s</root>') % NSMAP
|
|
90
|
+
root = etree.fromstring(wrapper % xml_str)
|
|
91
|
+
children = list(root)
|
|
92
|
+
for ch in children:
|
|
93
|
+
root.remove(ch)
|
|
94
|
+
return children
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def set_xfrm_flip_rot(shape, rotation=0, flip=None):
|
|
98
|
+
"""Apply rotation (deg cw) and flip [h,v] to an autoshape/picture."""
|
|
99
|
+
spPr = shape._element.spPr
|
|
100
|
+
xfrm = spPr.find(qn("a:xfrm"))
|
|
101
|
+
if xfrm is None:
|
|
102
|
+
return
|
|
103
|
+
if rotation:
|
|
104
|
+
xfrm.set("rot", str(int(round(rotation * 60000)) % 21600000))
|
|
105
|
+
if flip:
|
|
106
|
+
if flip[0]:
|
|
107
|
+
xfrm.set("flipH", "1")
|
|
108
|
+
if flip[1]:
|
|
109
|
+
xfrm.set("flipV", "1")
|