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/fonts.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""customFonts: fetch Google Fonts CSS, download woff2, convert to ttf,
|
|
2
|
+
embed into the pptx package as fntdata parts.
|
|
3
|
+
|
|
4
|
+
Requires fontTools+brotli (optional). Falls back to no embedding.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import urllib.request
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CACHE = Path.home() / ".cache" / "pptd_utils" / "fonts"
|
|
13
|
+
|
|
14
|
+
FNT_REL = ("http://schemas.openxmlformats.org/officeDocument/2006/"
|
|
15
|
+
"relationships/font")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fetch_custom_fonts(custom_fonts):
|
|
19
|
+
"""CustomFont[] -> [(family, ttf_bytes | None)]."""
|
|
20
|
+
out = []
|
|
21
|
+
for cf in custom_fonts or []:
|
|
22
|
+
family = cf.get("family")
|
|
23
|
+
src = cf.get("src")
|
|
24
|
+
ttf = None
|
|
25
|
+
try:
|
|
26
|
+
css = _fetch(src).decode("utf-8", "ignore")
|
|
27
|
+
# take latin subset woff2 (fallback: first woff2 found)
|
|
28
|
+
urls = re.findall(r"url\((https://[^)]+\.woff2)\)", css)
|
|
29
|
+
if urls:
|
|
30
|
+
# prefer the last block (usually latin)
|
|
31
|
+
woff2 = _fetch(urls[-1])
|
|
32
|
+
ttf = _woff2_to_ttf(woff2)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"pptd_utils: customFont {family!r} skipped: {e}")
|
|
35
|
+
out.append((family, ttf))
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fetch(url):
|
|
40
|
+
h = str(abs(hash(url)))
|
|
41
|
+
# cache by URL content — hash() is process-random; use sha1
|
|
42
|
+
import hashlib
|
|
43
|
+
h = hashlib.sha1(url.encode()).hexdigest()[:16]
|
|
44
|
+
dst = CACHE / h
|
|
45
|
+
if not dst.exists():
|
|
46
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
tmp = dst.with_suffix(".part")
|
|
48
|
+
urllib.request.urlretrieve(url, tmp)
|
|
49
|
+
tmp.rename(dst)
|
|
50
|
+
return dst.read_bytes()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _woff2_to_ttf(woff2):
|
|
54
|
+
try:
|
|
55
|
+
from fontTools.ttLib import TTFont
|
|
56
|
+
import io
|
|
57
|
+
font = TTFont(io.BytesIO(woff2))
|
|
58
|
+
font.flavor = None
|
|
59
|
+
buf = io.BytesIO()
|
|
60
|
+
font.save(buf)
|
|
61
|
+
return buf.getvalue()
|
|
62
|
+
except ImportError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def embed_fonts(pptx_path, fonts):
|
|
67
|
+
"""Post-process a saved .pptx: add fntdata parts + presentation wiring.
|
|
68
|
+
|
|
69
|
+
fonts: [(family, ttf_bytes)] — entries with None are skipped.
|
|
70
|
+
"""
|
|
71
|
+
fonts = [(f, b) for f, b in fonts if b]
|
|
72
|
+
if not fonts:
|
|
73
|
+
return 0
|
|
74
|
+
src = Path(pptx_path)
|
|
75
|
+
tmp = src.with_suffix(".pptx.tmp")
|
|
76
|
+
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(
|
|
77
|
+
tmp, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
78
|
+
names = zin.namelist()
|
|
79
|
+
# 1. content types
|
|
80
|
+
ct = zin.read("[Content_Types].xml").decode("utf-8")
|
|
81
|
+
if "fntdata" not in ct:
|
|
82
|
+
ct = ct.replace("</Types>",
|
|
83
|
+
'<Default Extension="fntdata" '
|
|
84
|
+
'ContentType="application/x-fontdata"/></Types>')
|
|
85
|
+
zout.writestr("[Content_Types].xml", ct)
|
|
86
|
+
# 2. presentation rels
|
|
87
|
+
rels_name = "ppt/_rels/presentation.xml.rels"
|
|
88
|
+
rels = zin.read(rels_name).decode("utf-8")
|
|
89
|
+
rid_max = 1
|
|
90
|
+
for m in re.finditer(r'Id="rId(\d+)"', rels):
|
|
91
|
+
rid_max = max(rid_max, int(m.group(1)) + 1)
|
|
92
|
+
new_rels = ""
|
|
93
|
+
font_parts = []
|
|
94
|
+
for i, (family, ttf) in enumerate(fonts, 1):
|
|
95
|
+
part = f"ppt/fonts/font{i}.fntdata"
|
|
96
|
+
font_parts.append((family, f"rId{rid_max}"))
|
|
97
|
+
new_rels += (f'<Relationship Id="rId{rid_max}" Type="{FNT_REL}" '
|
|
98
|
+
f'Target="fonts/font{i}.fntdata"/>')
|
|
99
|
+
rid_max += 1
|
|
100
|
+
zout.writestr(part, ttf)
|
|
101
|
+
rels = rels.replace("</Relationships>", new_rels + "</Relationships>")
|
|
102
|
+
zout.writestr(rels_name, rels)
|
|
103
|
+
# 3. presentation.xml: embeddedFontLst after notesSz (schema order)
|
|
104
|
+
pres = zin.read("ppt/presentation.xml").decode("utf-8")
|
|
105
|
+
if "embeddedFontLst" not in pres:
|
|
106
|
+
fl = "<p:embeddedFontLst>"
|
|
107
|
+
for family, rid in font_parts:
|
|
108
|
+
fl += (f'<p:embeddedFont><p:font typeface="{_esc(family)}"/>'
|
|
109
|
+
f'<p:regular r:id="{rid}"/></p:embeddedFont>')
|
|
110
|
+
fl += "</p:embeddedFontLst>"
|
|
111
|
+
m = re.search(r"<p:notesSz[^/]*/>", pres)
|
|
112
|
+
if m:
|
|
113
|
+
pres = pres[:m.end()] + fl + pres[m.end():]
|
|
114
|
+
else:
|
|
115
|
+
pres = pres.replace("</p:presentation>", fl + "</p:presentation>")
|
|
116
|
+
pres = pres.replace("<p:presentation ", '<p:presentation embedTrueTypeFonts="1" ', 1)
|
|
117
|
+
zout.writestr("ppt/presentation.xml", pres)
|
|
118
|
+
# 4. copy the rest
|
|
119
|
+
for name in names:
|
|
120
|
+
if name in ("[Content_Types].xml", rels_name,
|
|
121
|
+
"ppt/presentation.xml"):
|
|
122
|
+
continue
|
|
123
|
+
zout.writestr(name, zin.read(name))
|
|
124
|
+
shutil.move(str(tmp), str(src))
|
|
125
|
+
return len(fonts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _esc(s):
|
|
129
|
+
return (str(s).replace("&", "&").replace("<", "<")
|
|
130
|
+
.replace(">", ">").replace('"', """))
|
pptd_utils/icons.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Icon element: Font Awesome 6/7 free (fas/far/fab) rasterized via Pillow.
|
|
2
|
+
|
|
3
|
+
Supports solid + gradient fills, outline (border), drop shadow, opacity.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import urllib.request
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
11
|
+
from pptx.util import Pt
|
|
12
|
+
|
|
13
|
+
from .style import resolve_color, hex_to_rgba
|
|
14
|
+
from .xmlutil import set_xfrm_flip_rot
|
|
15
|
+
|
|
16
|
+
CACHE = Path.home() / ".cache" / "pptd_utils" / "fa"
|
|
17
|
+
FA_URLS = {
|
|
18
|
+
"fas": ("fa-solid-900.ttf", "https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.2/webfonts/fa-solid-900.ttf"),
|
|
19
|
+
"far": ("fa-regular-400.ttf", "https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.2/webfonts/fa-regular-400.ttf"),
|
|
20
|
+
"fab": ("fa-brands-400.ttf", "https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.2/webfonts/fa-brands-400.ttf"),
|
|
21
|
+
}
|
|
22
|
+
FA_META_URL = ("https://raw.githubusercontent.com/FortAwesome/"
|
|
23
|
+
"Font-Awesome/6.x/metadata/icons.json")
|
|
24
|
+
|
|
25
|
+
_META = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fa_meta():
|
|
29
|
+
global _META
|
|
30
|
+
if _META is None:
|
|
31
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
meta_path = CACHE / "icons.json"
|
|
33
|
+
if not meta_path.exists():
|
|
34
|
+
urllib.request.urlretrieve(FA_META_URL, meta_path)
|
|
35
|
+
_META = json.loads(meta_path.read_text())
|
|
36
|
+
return _META
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fa_ttf(style):
|
|
40
|
+
fname, url = FA_URLS[style]
|
|
41
|
+
path = CACHE / fname
|
|
42
|
+
if not path.exists():
|
|
43
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
urllib.request.urlretrieve(url, path)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _gradient_image(size, stops, angle, theme):
|
|
49
|
+
"""RGB gradient image (size) from stops [(pos, (r,g,b))]."""
|
|
50
|
+
c0 = hex_to_rgba(stops[0]["color"], theme)[:3] if len(stops) > 1 else (0, 0, 0)
|
|
51
|
+
c1 = hex_to_rgba(stops[-1]["color"], theme)[:3] if len(stops) > 1 else (255, 255, 255)
|
|
52
|
+
t = Image.linear_gradient("L").rotate(90 - (angle % 360), expand=True)
|
|
53
|
+
t = t.resize(size)
|
|
54
|
+
r = t.point(lambda v: int(c0[0] + (c1[0] - c0[0]) * v / 255))
|
|
55
|
+
g = t.point(lambda v: int(c0[1] + (c1[1] - c0[1]) * v / 255))
|
|
56
|
+
b = t.point(lambda v: int(c0[2] + (c1[2] - c0[2]) * v / 255))
|
|
57
|
+
return Image.merge("RGB", (r, g, b))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_icon(slide, el, theme):
|
|
61
|
+
style, name = str(el["iconName"]).split(":", 1)
|
|
62
|
+
if style not in FA_URLS:
|
|
63
|
+
raise ValueError(f"icon style {style!r} unsupported (fas/far/fab)")
|
|
64
|
+
meta = fa_meta()
|
|
65
|
+
m = meta.get(name)
|
|
66
|
+
if not m or "unicode" not in m:
|
|
67
|
+
raise ValueError(f"unknown FA icon {name!r}")
|
|
68
|
+
x, y, w, h = el["bounds"]
|
|
69
|
+
w, h = int(round(w)), int(round(h))
|
|
70
|
+
fill = el.get("fill") or {"type": "solid", "color": "#000000"}
|
|
71
|
+
ftype = fill.get("type", "solid")
|
|
72
|
+
opacity = float(el.get("opacity", 1.0))
|
|
73
|
+
glyph = chr(int(m["unicode"], 16))
|
|
74
|
+
border = el.get("border")
|
|
75
|
+
shadow = el.get("shadow")
|
|
76
|
+
|
|
77
|
+
rgb, _ = resolve_color(fill.get("color", "#000000"), theme, opacity)
|
|
78
|
+
parts = [style, name, str(rgb), f"{opacity:.3f}", f"{w}x{h}"]
|
|
79
|
+
if ftype == "gradient":
|
|
80
|
+
parts.append("g%s" % ",".join(s["color"] for s in fill.get("stops", [])) + f"@{fill.get('angle', 0)}")
|
|
81
|
+
if border:
|
|
82
|
+
bc, _ = resolve_color(border.get("color", "#000000"), theme)
|
|
83
|
+
parts.append(f"b{bc}_{border.get('width', 1)}")
|
|
84
|
+
if shadow:
|
|
85
|
+
sc, _ = resolve_color(shadow.get("color", "#000000"), theme)
|
|
86
|
+
off = shadow.get("offset") or [0, 0]
|
|
87
|
+
parts.append(f"s{sc}_{shadow.get('blur', 0)}_{off[0]},{off[1]}")
|
|
88
|
+
png = CACHE / ("icon_" + "_".join(str(p).replace("/", ".") for p in parts) + ".png")
|
|
89
|
+
|
|
90
|
+
if not png.exists():
|
|
91
|
+
S = 3
|
|
92
|
+
W, H = w * S, h * S
|
|
93
|
+
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
94
|
+
font = ImageFont.truetype(str(fa_ttf(style)), int(max(w, h) * S * 0.82))
|
|
95
|
+
anchor = (W // 2, H // 2)
|
|
96
|
+
stroke_w = int(border.get("width", 1) * S) if border else 0
|
|
97
|
+
if shadow:
|
|
98
|
+
sc_rgb, _ = resolve_color(shadow.get("color", "#000000"), theme)
|
|
99
|
+
soff = shadow.get("offset") or [0, 0]
|
|
100
|
+
lay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
101
|
+
ImageDraw.Draw(lay).text(
|
|
102
|
+
(anchor[0] + int(soff[0] * S), anchor[1] + int(soff[1] * S)),
|
|
103
|
+
glyph, font=font,
|
|
104
|
+
fill=(*sc_rgb, int(255 * opacity)),
|
|
105
|
+
stroke_width=stroke_w,
|
|
106
|
+
stroke_fill=(*sc_rgb, int(255 * opacity)))
|
|
107
|
+
lay = lay.filter(ImageFilter.GaussianBlur(max(1, int(shadow.get("blur", 4) * S / 2))))
|
|
108
|
+
img.alpha_composite(lay)
|
|
109
|
+
mask = Image.new("L", (W, H), 0)
|
|
110
|
+
ImageDraw.Draw(mask).text(anchor, glyph, font=font, fill=255,
|
|
111
|
+
stroke_width=stroke_w, stroke_fill=255)
|
|
112
|
+
if ftype == "gradient":
|
|
113
|
+
col = _gradient_image((W, H), fill.get("stops", []), float(fill.get("angle", 0)), theme).convert("RGBA")
|
|
114
|
+
else:
|
|
115
|
+
col = Image.new("RGBA", (W, H), (*rgb, int(255 * opacity)))
|
|
116
|
+
alpha_mask = mask if opacity >= 1 else mask.point(lambda v: int(v * opacity))
|
|
117
|
+
body = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
118
|
+
body.paste(col, (0, 0), alpha_mask)
|
|
119
|
+
img.alpha_composite(body)
|
|
120
|
+
if border:
|
|
121
|
+
bc, _ = resolve_color(border.get("color", "#000000"), theme, opacity)
|
|
122
|
+
inner = Image.new("L", (W, H), 0)
|
|
123
|
+
ImageDraw.Draw(inner).text(anchor, glyph, font=font, fill=255)
|
|
124
|
+
ring = Image.new("L", (W, H), 0)
|
|
125
|
+
ring.paste(mask, (0, 0))
|
|
126
|
+
ring.paste(0, (0, 0), inner)
|
|
127
|
+
ring_col = Image.new("RGBA", (W, H), (*bc, 255))
|
|
128
|
+
outl = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
129
|
+
outl.paste(ring_col, (0, 0), ring if opacity >= 1 else ring.point(lambda v: int(v * opacity)))
|
|
130
|
+
img.alpha_composite(outl)
|
|
131
|
+
img.resize((w, h), Image.LANCZOS).save(png)
|
|
132
|
+
|
|
133
|
+
pic = slide.shapes.add_picture(str(png), Pt(x), Pt(y), Pt(w), Pt(h))
|
|
134
|
+
set_xfrm_flip_rot(pic, el.get("rotation") or 0, el.get("flip"))
|
|
135
|
+
return pic
|
pptd_utils/images.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Image element: fit (cover/contain/fill), crop (incl. negative outset),
|
|
2
|
+
cropShape clipping, border, shadow, opacity, rotation/flip."""
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from PIL import Image as PILImage
|
|
7
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
8
|
+
from pptx.oxml.ns import qn
|
|
9
|
+
from pptx.util import Pt
|
|
10
|
+
|
|
11
|
+
from .shapes import apply_preset_geometry, build_custgeom, _apply_alpha
|
|
12
|
+
from .style import fetch_url, cover_src_rect, ln_xml, shadow_xml
|
|
13
|
+
from .xmlutil import sub, set_xfrm_flip_rot, insert_ordered, SPPR_ORDER
|
|
14
|
+
|
|
15
|
+
PREP_CACHE = Path.home() / ".cache" / "pptd_utils" / "prepped"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _prep_negative_crop(src, crop):
|
|
19
|
+
"""Negative crop values -> expand canvas with transparency; returns path."""
|
|
20
|
+
l = crop.get("left", 0) or 0
|
|
21
|
+
t = crop.get("top", 0) or 0
|
|
22
|
+
r = crop.get("right", 0) or 0
|
|
23
|
+
b = crop.get("bottom", 0) or 0
|
|
24
|
+
if min(l, t, r, b) >= 0:
|
|
25
|
+
return src
|
|
26
|
+
key = hashlib.sha1(f"{src}|{l},{t},{r},{b}".encode()).hexdigest()[:16]
|
|
27
|
+
dst = PREP_CACHE / f"{key}.png"
|
|
28
|
+
if not dst.exists():
|
|
29
|
+
PREP_CACHE.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with PILImage.open(src) as im:
|
|
31
|
+
im = im.convert("RGBA")
|
|
32
|
+
w, h = im.size
|
|
33
|
+
pad = (max(0, -l) * w, max(0, -t) * h, max(0, -r) * w, max(0, -b) * h)
|
|
34
|
+
canvas = PILImage.new("RGBA", (w + int(pad[0] + pad[2]), h + int(pad[1] + pad[3])), (0, 0, 0, 0))
|
|
35
|
+
canvas.paste(im, (int(pad[0]), int(pad[1])))
|
|
36
|
+
canvas.save(dst)
|
|
37
|
+
return dst
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_image(slide, el, theme, root):
|
|
41
|
+
src = el["src"]
|
|
42
|
+
path = fetch_url(src) if str(src).startswith(("http://", "https://")) else (root / src)
|
|
43
|
+
x, y, w, h = el["bounds"]
|
|
44
|
+
crop = el.get("crop") or {}
|
|
45
|
+
if isinstance(crop, (list, tuple)):
|
|
46
|
+
crop = dict(zip(("left", "top", "right", "bottom"), crop))
|
|
47
|
+
path = _prep_negative_crop(path, crop)
|
|
48
|
+
pos_crop = {k: max(0.0, v) for k, v in crop.items() if (v or 0) > 0} if crop else {}
|
|
49
|
+
|
|
50
|
+
fite = el.get("fit") or {}
|
|
51
|
+
fit = fite.get("mode", "cover") if isinstance(fite, dict) else str(fite)
|
|
52
|
+
px, py, pw, ph = x, y, w, h
|
|
53
|
+
src_rect = None
|
|
54
|
+
if fit == "cover":
|
|
55
|
+
src_rect = cover_src_rect(path, w, h, pos_crop or None)
|
|
56
|
+
elif fit == "contain":
|
|
57
|
+
try:
|
|
58
|
+
with PILImage.open(path) as im:
|
|
59
|
+
iw, ih = im.size
|
|
60
|
+
except Exception:
|
|
61
|
+
iw = ih = 1
|
|
62
|
+
if iw and ih and w and h:
|
|
63
|
+
if iw / ih > w / h:
|
|
64
|
+
pw = w
|
|
65
|
+
ph = w * ih / iw
|
|
66
|
+
else:
|
|
67
|
+
ph = h
|
|
68
|
+
pw = h * iw / ih
|
|
69
|
+
px = x + (w - pw) / 2
|
|
70
|
+
py = y + (h - ph) / 2
|
|
71
|
+
|
|
72
|
+
pic = slide.shapes.add_picture(str(path), Pt(px), Pt(py), Pt(pw), Pt(ph))
|
|
73
|
+
blipFill = pic._element.blipFill
|
|
74
|
+
if src_rect:
|
|
75
|
+
st = blipFill.find(qn("a:stretch"))
|
|
76
|
+
sr = sub(blipFill, "a:srcRect", src_rect)
|
|
77
|
+
if st is not None:
|
|
78
|
+
st.addprevious(sr)
|
|
79
|
+
opacity = el.get("opacity", 1.0)
|
|
80
|
+
if opacity is not None and float(opacity) < 1.0:
|
|
81
|
+
blip = blipFill.find(qn("a:blip"))
|
|
82
|
+
sub(blip, "a:alphaModFix", {"amt": str(int(float(opacity) * 100000))})
|
|
83
|
+
|
|
84
|
+
spPr = pic._element.spPr
|
|
85
|
+
cs = el.get("cropShape")
|
|
86
|
+
if isinstance(cs, str):
|
|
87
|
+
cs = {"shapeName": cs}
|
|
88
|
+
if cs:
|
|
89
|
+
name = cs.get("shapeName")
|
|
90
|
+
if name == "custom":
|
|
91
|
+
build_custgeom(spPr, cs.get("viewBox", [pw, ph]), cs.get("path", ""))
|
|
92
|
+
else:
|
|
93
|
+
geom = spPr.find(qn("a:prstGeom"))
|
|
94
|
+
if geom is None:
|
|
95
|
+
geom = insert_ordered(spPr, sub(spPr, "a:prstGeom", {"prst": "rect"}), SPPR_ORDER)
|
|
96
|
+
else:
|
|
97
|
+
geom.set("prst", name or "rect")
|
|
98
|
+
avLst = geom.find(qn("a:avLst"))
|
|
99
|
+
for gd in list(avLst):
|
|
100
|
+
avLst.remove(gd)
|
|
101
|
+
apply_preset_geometry(pic, name, cs.get("adjustments"))
|
|
102
|
+
ln_xml(spPr, el.get("border"), theme)
|
|
103
|
+
shadow_xml(spPr, el.get("shadow"), theme)
|
|
104
|
+
set_xfrm_flip_rot(pic, el.get("rotation") or 0, el.get("flip"))
|
|
105
|
+
return pic
|
pptd_utils/lines.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Line element: bezier/rounded/sharp polylines via custGeom + arrowheads."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
|
5
|
+
from pptx.oxml.ns import qn
|
|
6
|
+
from pptx.util import Pt
|
|
7
|
+
from lxml import etree
|
|
8
|
+
|
|
9
|
+
from .shapes import build_custgeom, _ipf
|
|
10
|
+
from .style import ln_xml, shadow_xml
|
|
11
|
+
from .xmlutil import sub, set_xfrm_flip_rot
|
|
12
|
+
|
|
13
|
+
ARROW_MAP = {"arrow": "triangle", "stealth": "stealth",
|
|
14
|
+
"diamond": "diamond", "oval": "oval"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pts(points):
|
|
18
|
+
toks = str(points).replace(",", " ").split()
|
|
19
|
+
out = []
|
|
20
|
+
for i in range(0, len(toks) - 1, 2):
|
|
21
|
+
out.append((float(toks[i]), float(toks[i + 1])))
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _path_cmds(pts, curve):
|
|
26
|
+
"""Build (tag, vals) list in viewBox coords from point list."""
|
|
27
|
+
n = len(pts)
|
|
28
|
+
cmds = [("M", [pts[0][0], pts[0][1]])]
|
|
29
|
+
if curve is None or curve == "sharp" or n == 2:
|
|
30
|
+
for p in pts[1:]:
|
|
31
|
+
cmds.append(("L", [p[0], p[1]]))
|
|
32
|
+
return cmds
|
|
33
|
+
if curve == "smooth":
|
|
34
|
+
i = 1
|
|
35
|
+
while i < n:
|
|
36
|
+
if n - i >= 3:
|
|
37
|
+
cmds.append(("C", [pts[i][0], pts[i][1], pts[i + 1][0],
|
|
38
|
+
pts[i + 1][1], pts[i + 2][0], pts[i + 2][1]]))
|
|
39
|
+
i += 3
|
|
40
|
+
elif n - i == 2:
|
|
41
|
+
# quadratic through one control
|
|
42
|
+
x0, y0 = pts[i - 1]
|
|
43
|
+
x1, y1 = pts[i]
|
|
44
|
+
x2, y2 = pts[i + 1]
|
|
45
|
+
cx1 = x0 + 2 / 3 * (x1 - x0)
|
|
46
|
+
cy1 = y0 + 2 / 3 * (y1 - y0)
|
|
47
|
+
cx2 = x2 + 2 / 3 * (x1 - x2)
|
|
48
|
+
cy2 = y2 + 2 / 3 * (y1 - y2)
|
|
49
|
+
cmds.append(("C", [cx1, cy1, cx2, cy2, x2, y2]))
|
|
50
|
+
i += 2
|
|
51
|
+
else:
|
|
52
|
+
cmds.append(("L", [pts[i][0], pts[i][1]]))
|
|
53
|
+
i += 1
|
|
54
|
+
return cmds
|
|
55
|
+
# round: polyline with corner fillets
|
|
56
|
+
r = None
|
|
57
|
+
for i in range(1, n - 1):
|
|
58
|
+
e1 = math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])
|
|
59
|
+
e2 = math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
|
|
60
|
+
cand = min(e1, e2) / 3.0
|
|
61
|
+
r = cand if r is None else min(r, cand)
|
|
62
|
+
r = min(r or 0, 12.0)
|
|
63
|
+
if r < 0.01:
|
|
64
|
+
for p in pts[1:]:
|
|
65
|
+
cmds.append(("L", [p[0], p[1]]))
|
|
66
|
+
return cmds
|
|
67
|
+
cur = pts[0]
|
|
68
|
+
for i in range(1, n - 1):
|
|
69
|
+
p, nx = pts[i], pts[i + 1]
|
|
70
|
+
d1 = (p[0] - cur[0], p[1] - cur[1])
|
|
71
|
+
l1 = math.hypot(*d1) or 1.0
|
|
72
|
+
d2 = (nx[0] - p[0], nx[1] - p[1])
|
|
73
|
+
l2 = math.hypot(*d2) or 1.0
|
|
74
|
+
r1 = min(r, l1 / 2)
|
|
75
|
+
r2 = min(r, l2 / 2)
|
|
76
|
+
c1 = (p[0] - d1[0] / l1 * r1, p[1] - d1[1] / l1 * r1)
|
|
77
|
+
c2 = (p[0] + d2[0] / l2 * r2, p[1] + d2[1] / l2 * r2)
|
|
78
|
+
cmds.append(("L", [c1[0], c1[1]]))
|
|
79
|
+
cmds.append(("Q", [p[0], p[1], c2[0], c2[1]]))
|
|
80
|
+
cur = c2
|
|
81
|
+
last = pts[-1]
|
|
82
|
+
cmds.append(("L", [last[0], last[1]]))
|
|
83
|
+
return cmds
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _emit_path(path_el, cmds):
|
|
87
|
+
for tag, vals in cmds:
|
|
88
|
+
if tag == "M":
|
|
89
|
+
mt = sub(path_el, "a:moveTo")
|
|
90
|
+
pt = sub(mt, "a:pt", {"x": _i(vals[0]), "y": _i(vals[1])})
|
|
91
|
+
elif tag == "L":
|
|
92
|
+
lt = sub(path_el, "a:lnTo")
|
|
93
|
+
sub(lt, "a:pt", {"x": _i(vals[0]), "y": _i(vals[1])})
|
|
94
|
+
elif tag == "C":
|
|
95
|
+
cb = sub(path_el, "a:cubicBezTo")
|
|
96
|
+
for j in (0, 2, 4):
|
|
97
|
+
sub(cb, "a:pt", {"x": _i(vals[j]), "y": _i(vals[j + 1])})
|
|
98
|
+
elif tag == "Q":
|
|
99
|
+
qb = sub(path_el, "a:quadBezTo")
|
|
100
|
+
for j in (0, 2):
|
|
101
|
+
sub(qb, "a:pt", {"x": _i(vals[j]), "y": _i(vals[j + 1])})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _i(v):
|
|
105
|
+
return str(int(round(float(v))))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_line(slide, el, theme):
|
|
109
|
+
pts = _pts(el.get("points", ""))
|
|
110
|
+
if len(pts) < 2:
|
|
111
|
+
raise ValueError("line needs >= 2 points")
|
|
112
|
+
x, y, w, h = el["bounds"]
|
|
113
|
+
vw, vh = el.get("viewBox", [w, h])
|
|
114
|
+
|
|
115
|
+
# Use a native connector for ordinary 2-point lines. PowerPoint can drop
|
|
116
|
+
# custom-geometry line shapes even though cowork/browser renderers display them.
|
|
117
|
+
if len(pts) == 2:
|
|
118
|
+
sx, sy = pts[0]
|
|
119
|
+
ex, ey = pts[1]
|
|
120
|
+
x1, y1 = x + sx / vw * w, y + sy / vh * h
|
|
121
|
+
x2, y2 = x + ex / vw * w, y + ey / vh * h
|
|
122
|
+
shape = slide.shapes.add_connector(
|
|
123
|
+
MSO_CONNECTOR.STRAIGHT, Pt(x1), Pt(y1), Pt(x2), Pt(y2))
|
|
124
|
+
shape.shadow.inherit = False
|
|
125
|
+
spPr = shape._element.spPr
|
|
126
|
+
border = dict(el.get("border") or {})
|
|
127
|
+
border.setdefault("width", 2)
|
|
128
|
+
border.setdefault("color", "#000000")
|
|
129
|
+
ln = ln_xml(spPr, border, theme)
|
|
130
|
+
arrows = el.get("arrow") or [None, None]
|
|
131
|
+
if arrows[0]:
|
|
132
|
+
sub(ln, "a:headEnd", {"type": ARROW_MAP.get(arrows[0], "triangle"),
|
|
133
|
+
"w": "med", "len": "med"})
|
|
134
|
+
if arrows[1]:
|
|
135
|
+
sub(ln, "a:tailEnd", {"type": ARROW_MAP.get(arrows[1], "triangle"),
|
|
136
|
+
"w": "med", "len": "med"})
|
|
137
|
+
set_xfrm_flip_rot(shape, el.get("rotation") or 0, el.get("flip"))
|
|
138
|
+
return shape
|
|
139
|
+
|
|
140
|
+
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Pt(x), Pt(y), Pt(w), Pt(h))
|
|
141
|
+
shape.shadow.inherit = False
|
|
142
|
+
spPr = shape._element.spPr
|
|
143
|
+
# geometry
|
|
144
|
+
for t in ("a:prstGeom", "a:custGeom"):
|
|
145
|
+
e = spPr.find(qn(t))
|
|
146
|
+
if e is not None:
|
|
147
|
+
spPr.remove(e)
|
|
148
|
+
cust = etree.Element(qn("a:custGeom"))
|
|
149
|
+
spPr.insert(0, cust)
|
|
150
|
+
sub(cust, "a:avLst")
|
|
151
|
+
sub(cust, "a:gdLst")
|
|
152
|
+
pathLst = sub(cust, "a:pathLst")
|
|
153
|
+
path = sub(pathLst, "a:path", {"w": _i(vw), "h": _i(vh)})
|
|
154
|
+
_emit_path(path, _path_cmds(pts, el.get("curve", "round")))
|
|
155
|
+
# no fill, line style
|
|
156
|
+
for e in spPr.findall(qn("a:noFill")):
|
|
157
|
+
spPr.remove(e)
|
|
158
|
+
from .xmlutil import SPPR_ORDER, insert_ordered
|
|
159
|
+
insert_ordered(spPr, etree.Element(qn("a:noFill")), SPPR_ORDER)
|
|
160
|
+
border = el.get("border") or {}
|
|
161
|
+
border = dict(border)
|
|
162
|
+
border.setdefault("width", 2)
|
|
163
|
+
border.setdefault("color", "#000000")
|
|
164
|
+
ln = ln_xml(spPr, border, theme)
|
|
165
|
+
arrows = el.get("arrow") or [None, None]
|
|
166
|
+
if arrows[0]:
|
|
167
|
+
sub(ln, "a:headEnd", {"type": ARROW_MAP.get(arrows[0], "triangle"),
|
|
168
|
+
"w": "med", "len": "med"})
|
|
169
|
+
if arrows[1]:
|
|
170
|
+
sub(ln, "a:tailEnd", {"type": ARROW_MAP.get(arrows[1], "triangle"),
|
|
171
|
+
"w": "med", "len": "med"})
|
|
172
|
+
opacity = el.get("opacity", 1.0)
|
|
173
|
+
if opacity is not None and float(opacity) < 1.0:
|
|
174
|
+
val = str(int(float(opacity) * 100000))
|
|
175
|
+
for srgb in spPr.iter(qn("a:srgbClr")):
|
|
176
|
+
if srgb.find(qn("a:alpha")) is None:
|
|
177
|
+
sub(srgb, "a:alpha", {"val": val})
|
|
178
|
+
shadow_xml(spPr, el.get("shadow"), theme)
|
|
179
|
+
set_xfrm_flip_rot(shape, el.get("rotation") or 0, el.get("flip"))
|
|
180
|
+
return shape
|