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/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")