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/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("&", "&amp;").replace("<", "&lt;")
130
+ .replace(">", "&gt;").replace('"', "&quot;"))
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