python-pptx2 2.13.0__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.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/geometry.py ADDED
@@ -0,0 +1,420 @@
1
+ """Geometry primitives — ``BBox`` and friends.
2
+
3
+ A first-class value object for rectangular slide regions. EMU arithmetic
4
+ is verbose and error-prone; ``BBox`` does it for you::
5
+
6
+ from pptx2 import BBox, Inches
7
+
8
+ bb = BBox.from_inches(1, 2, 8, 4)
9
+ inner = bb.inset(all=Inches(0.2))
10
+ left, right = bb.split_h([1, 1], gap=Inches(0.1))
11
+
12
+ ``BBox`` is immutable, hashable, and unpacks to
13
+ ``(left, top, width, height)`` so it can be passed in place of the
14
+ historical 4-tuple to ``add_shape`` / ``add_textbox`` etc. when those
15
+ APIs accept ``*box``.
16
+
17
+ Every constructor returns a ``BBox`` whose four members are
18
+ :class:`~pptx2.util.Emu` instances — assigning ``bb.left`` to a
19
+ shape's ``shape.left`` works straight through.
20
+
21
+ See the parent skill notes for the full motivation; this module's
22
+ existence is item 1.3 of the v2.7-era recommendations.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Iterable, Iterator, Sequence
29
+
30
+ from pptx2.util import Emu, Inches, Length, _coerce_emu
31
+
32
+ if TYPE_CHECKING:
33
+ from pptx2.shapes.base import BaseShape
34
+
35
+
36
+ __all__ = ["BBox"]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BBox:
41
+ """An immutable rectangular region in EMU.
42
+
43
+ ``left``, ``top``, ``width``, ``height`` are integer EMU values
44
+ stored as :class:`~pptx2.util.Emu`. Negative dimensions are
45
+ rejected; negative positions are accepted (a shape can sit off the
46
+ slide).
47
+
48
+ Iteration yields ``(left, top, width, height)`` so the box can be
49
+ splatted into APIs that accept four positional length arguments::
50
+
51
+ bb = BBox.from_inches(1, 2, 4, 3)
52
+ rect = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, *bb)
53
+ """
54
+
55
+ left: Emu
56
+ top: Emu
57
+ width: Emu
58
+ height: Emu
59
+
60
+ def __post_init__(self) -> None:
61
+ # Coerce to Emu so callers can construct from plain ints / floats
62
+ # / Length subclasses without remembering the right wrapper.
63
+ # ``__setattr__`` is needed because the dataclass is frozen.
64
+ for name in ("left", "top", "width", "height"):
65
+ object.__setattr__(self, name, Emu(_coerce_emu(getattr(self, name))))
66
+ if int(self.width) < 0:
67
+ raise ValueError(f"BBox.width must be >= 0, got {int(self.width)}")
68
+ if int(self.height) < 0:
69
+ raise ValueError(f"BBox.height must be >= 0, got {int(self.height)}")
70
+
71
+ # -------------------------------------------------------------- constructors
72
+
73
+ @classmethod
74
+ def from_shape(cls, shape: "BaseShape") -> "BBox":
75
+ """Return the BBox of a shape (``left``, ``top``, ``width``, ``height``).
76
+
77
+ Works for any object exposing those four attributes — shapes,
78
+ pictures, group shapes, placeholders.
79
+ """
80
+ return cls(shape.left, shape.top, shape.width, shape.height)
81
+
82
+ @classmethod
83
+ def from_inches(
84
+ cls, left: float, top: float, width: float, height: float
85
+ ) -> "BBox":
86
+ """Construct a BBox with positions in inches."""
87
+ return cls(Inches(left), Inches(top), Inches(width), Inches(height))
88
+
89
+ @classmethod
90
+ def from_emu(
91
+ cls, left: int, top: int, width: int, height: int
92
+ ) -> "BBox":
93
+ """Construct a BBox with positions in raw EMU."""
94
+ return cls(Emu(left), Emu(top), Emu(width), Emu(height))
95
+
96
+ @classmethod
97
+ def from_slide(cls, slide) -> "BBox":
98
+ """Return the BBox covering the full slide area."""
99
+ prs = slide.part.package.presentation_part.presentation
100
+ return cls(Emu(0), Emu(0), Emu(int(prs.slide_width)), Emu(int(prs.slide_height)))
101
+
102
+ # -------------------------------------------------------------- properties
103
+
104
+ @property
105
+ def right(self) -> Emu:
106
+ """X coordinate of the right edge (``left + width``)."""
107
+ return Emu(int(self.left) + int(self.width))
108
+
109
+ @property
110
+ def bottom(self) -> Emu:
111
+ """Y coordinate of the bottom edge (``top + height``)."""
112
+ return Emu(int(self.top) + int(self.height))
113
+
114
+ @property
115
+ def cx(self) -> Emu:
116
+ """Horizontal centre of the box."""
117
+ return Emu(int(self.left) + int(self.width) // 2)
118
+
119
+ @property
120
+ def cy(self) -> Emu:
121
+ """Vertical centre of the box."""
122
+ return Emu(int(self.top) + int(self.height) // 2)
123
+
124
+ @property
125
+ def area(self) -> int:
126
+ return int(self.width) * int(self.height)
127
+
128
+ # -------------------------------------------------------------- iteration
129
+
130
+ def __iter__(self) -> Iterator[Emu]:
131
+ # Order matches the historical (left, top, width, height)
132
+ # positional argument convention used across add_shape, add_textbox,
133
+ # add_picture, etc.
134
+ yield self.left
135
+ yield self.top
136
+ yield self.width
137
+ yield self.height
138
+
139
+ def as_tuple(self) -> tuple[Emu, Emu, Emu, Emu]:
140
+ """Return ``(left, top, width, height)``."""
141
+ return (self.left, self.top, self.width, self.height)
142
+
143
+ # -------------------------------------------------------------- transforms
144
+
145
+ def shifted(self, dx: int = 0, dy: int = 0) -> "BBox":
146
+ """Return a new BBox translated by ``(dx, dy)`` EMU."""
147
+ return BBox(
148
+ Emu(int(self.left) + int(dx)),
149
+ Emu(int(self.top) + int(dy)),
150
+ self.width,
151
+ self.height,
152
+ )
153
+
154
+ def resized(
155
+ self,
156
+ *,
157
+ width: int | None = None,
158
+ height: int | None = None,
159
+ ) -> "BBox":
160
+ """Return a new BBox with ``width`` and/or ``height`` overridden."""
161
+ return BBox(
162
+ self.left,
163
+ self.top,
164
+ Emu(int(width)) if width is not None else self.width,
165
+ Emu(int(height)) if height is not None else self.height,
166
+ )
167
+
168
+ def inset(
169
+ self,
170
+ *,
171
+ all: int | None = None,
172
+ x: int | None = None,
173
+ y: int | None = None,
174
+ left: int | None = None,
175
+ top: int | None = None,
176
+ right: int | None = None,
177
+ bottom: int | None = None,
178
+ ) -> "BBox":
179
+ """Return a BBox shrunk inwards by the given padding amounts.
180
+
181
+ ``all`` sets every side; ``x`` sets left+right; ``y`` sets
182
+ top+bottom; the four per-edge kwargs override anything else.
183
+ Defaults to zero — equivalent to a no-op when called with no args.
184
+
185
+ Negative values expand the box outward.
186
+ """
187
+ l_in = _resolve_inset(left, x, all, 0)
188
+ r_in = _resolve_inset(right, x, all, 0)
189
+ t_in = _resolve_inset(top, y, all, 0)
190
+ b_in = _resolve_inset(bottom, y, all, 0)
191
+ new_left = int(self.left) + l_in
192
+ new_top = int(self.top) + t_in
193
+ new_w = int(self.width) - l_in - r_in
194
+ new_h = int(self.height) - t_in - b_in
195
+ if new_w < 0:
196
+ new_w = 0
197
+ if new_h < 0:
198
+ new_h = 0
199
+ return BBox(Emu(new_left), Emu(new_top), Emu(new_w), Emu(new_h))
200
+
201
+ def sub(self, fx: float, fy: float, fw: float, fh: float) -> "BBox":
202
+ """Return a normalised sub-box (each arg in ``[0.0, 1.0]``).
203
+
204
+ ``fx``/``fy`` are relative offsets, ``fw``/``fh`` are relative
205
+ widths/heights — so ``box.sub(0.25, 0, 0.5, 1.0)`` returns the
206
+ middle-half-width strip of the box.
207
+ """
208
+ return BBox(
209
+ Emu(int(self.left) + int(round(fx * int(self.width)))),
210
+ Emu(int(self.top) + int(round(fy * int(self.height)))),
211
+ Emu(int(round(fw * int(self.width)))),
212
+ Emu(int(round(fh * int(self.height)))),
213
+ )
214
+
215
+ # -------------------------------------------------------------- splits
216
+
217
+ def split_h(
218
+ self, ratios: Sequence[float], gap: int = 0
219
+ ) -> list["BBox"]:
220
+ """Split horizontally into N columns with the given ratios.
221
+
222
+ ``ratios=[1, 1]`` produces two equal columns; ``[2, 1]`` makes
223
+ the first column twice as wide as the second. ``gap`` is the
224
+ EMU gap between consecutive columns.
225
+ """
226
+ return _split(self, ratios, gap, axis="h")
227
+
228
+ def split_v(
229
+ self, ratios: Sequence[float], gap: int = 0
230
+ ) -> list["BBox"]:
231
+ """Split vertically into N rows with the given ratios."""
232
+ return _split(self, ratios, gap, axis="v")
233
+
234
+ def columns(self, n: int, gap: int = 0) -> list["BBox"]:
235
+ """Return `n` equal-width columns of this box, separated by `gap`.
236
+
237
+ The shorthand for the arithmetic every multi-column layout would
238
+ otherwise hand-roll (``col_w = (avail - (n - 1) * gap) / n`` and a
239
+ running cursor)::
240
+
241
+ for box, item in zip(row.columns(3, gap=Pt(16)), items):
242
+ card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, *box)
243
+
244
+ Widths are apportioned so the columns partition the box exactly — no
245
+ rounding drift on the last column. For unequal columns use
246
+ :meth:`split_h` with explicit ratios.
247
+ """
248
+ return _split(self, [1] * _positive_count(n, "columns"), gap, axis="h")
249
+
250
+ def rows(self, n: int, gap: int = 0) -> list["BBox"]:
251
+ """Return `n` equal-height rows of this box, separated by `gap`.
252
+
253
+ The vertical twin of :meth:`columns`; use :meth:`split_v` for
254
+ unequal rows.
255
+ """
256
+ return _split(self, [1] * _positive_count(n, "rows"), gap, axis="v")
257
+
258
+ def grid(
259
+ self,
260
+ cols: int,
261
+ rows: int = 1,
262
+ *,
263
+ gap_x: int = 0,
264
+ gap_y: int = 0,
265
+ ) -> list["BBox"]:
266
+ """Return a flat list of ``cols * rows`` equal cells (row-major)."""
267
+ if cols < 1 or rows < 1:
268
+ raise ValueError(
269
+ f"cols and rows must be >= 1; got cols={cols}, rows={rows}"
270
+ )
271
+ col_boxes = _split(self, [1] * cols, gap_x, axis="h")
272
+ # Pre-split each column into rows once; flatten in row-major order.
273
+ col_rows = [_split(cb, [1] * rows, gap_y, axis="v") for cb in col_boxes]
274
+ return [col_rows[c][r] for r in range(rows) for c in range(cols)]
275
+
276
+ # -------------------------------------------------------------- geometric
277
+
278
+ def contains(self, other: "BBox", *, tol: int = 0) -> bool:
279
+ """True if ``other`` lies entirely inside this box (within ``tol``)."""
280
+ return (
281
+ int(other.left) >= int(self.left) - tol
282
+ and int(other.top) >= int(self.top) - tol
283
+ and int(other.right) <= int(self.right) + tol
284
+ and int(other.bottom) <= int(self.bottom) + tol
285
+ )
286
+
287
+ def intersects(self, other: "BBox") -> bool:
288
+ """True if this box overlaps ``other``.
289
+
290
+ Touching edges do *not* count as intersection — two boxes
291
+ sharing an edge or a corner return ``False``. This matches
292
+ the standard "overlap = shared area" interpretation.
293
+ """
294
+ return not (
295
+ int(self.right) <= int(other.left)
296
+ or int(other.right) <= int(self.left)
297
+ or int(self.bottom) <= int(other.top)
298
+ or int(other.bottom) <= int(self.top)
299
+ )
300
+
301
+ def intersection(self, other: "BBox") -> "BBox":
302
+ """Return the overlapping region, or a zero-area box if none."""
303
+ ix = max(int(self.left), int(other.left))
304
+ iy = max(int(self.top), int(other.top))
305
+ ix2 = min(int(self.right), int(other.right))
306
+ iy2 = min(int(self.bottom), int(other.bottom))
307
+ if ix2 < ix or iy2 < iy:
308
+ return BBox(Emu(ix), Emu(iy), Emu(0), Emu(0))
309
+ return BBox(Emu(ix), Emu(iy), Emu(ix2 - ix), Emu(iy2 - iy))
310
+
311
+ def union(self, other: "BBox") -> "BBox":
312
+ """Return the smallest box enclosing both rectangles."""
313
+ ix = min(int(self.left), int(other.left))
314
+ iy = min(int(self.top), int(other.top))
315
+ ix2 = max(int(self.right), int(other.right))
316
+ iy2 = max(int(self.bottom), int(other.bottom))
317
+ return BBox(Emu(ix), Emu(iy), Emu(ix2 - ix), Emu(iy2 - iy))
318
+
319
+ # -------------------------------------------------------------- application
320
+
321
+ def apply_to(self, shape: "BaseShape") -> "BaseShape":
322
+ """Set ``shape.left/top/width/height`` from this box."""
323
+ shape.left = self.left
324
+ shape.top = self.top
325
+ shape.width = self.width
326
+ shape.height = self.height
327
+ return shape
328
+
329
+
330
+ def _positive_count(n: int, name: str) -> int:
331
+ """Return `n` as an int, rejecting counts below 1."""
332
+ count = int(n)
333
+ if count < 1:
334
+ raise ValueError(f"BBox.{name}() needs n >= 1, got {n!r}")
335
+ return count
336
+
337
+
338
+ def _resolve_inset(
339
+ edge: int | None,
340
+ axis: int | None,
341
+ all_: int | None,
342
+ default: int,
343
+ ) -> int:
344
+ """Combine the inset kwargs in order of specificity."""
345
+ if edge is not None:
346
+ return int(edge)
347
+ if axis is not None:
348
+ return int(axis)
349
+ if all_ is not None:
350
+ return int(all_)
351
+ return default
352
+
353
+
354
+ def _split(
355
+ box: BBox, ratios: Sequence[float], gap: int, *, axis: str
356
+ ) -> list[BBox]:
357
+ if not ratios:
358
+ raise ValueError("ratios must not be empty")
359
+ if any(r < 0 for r in ratios):
360
+ raise ValueError("ratios must be non-negative")
361
+ total = float(sum(ratios))
362
+ if total <= 0:
363
+ raise ValueError("at least one ratio must be > 0")
364
+
365
+ if axis == "h":
366
+ span = int(box.width) - int(gap) * (len(ratios) - 1)
367
+ if span < 0:
368
+ raise ValueError(
369
+ f"horizontal gaps ({len(ratios) - 1}×{int(gap)}) consume the "
370
+ f"entire box width ({int(box.width)} EMU)"
371
+ )
372
+ widths = _apportion(span, ratios, total)
373
+ out: list[BBox] = []
374
+ cursor = int(box.left)
375
+ for w in widths:
376
+ out.append(BBox(Emu(cursor), box.top, Emu(w), box.height))
377
+ cursor += w + int(gap)
378
+ return out
379
+ elif axis == "v":
380
+ span = int(box.height) - int(gap) * (len(ratios) - 1)
381
+ if span < 0:
382
+ raise ValueError(
383
+ f"vertical gaps ({len(ratios) - 1}×{int(gap)}) consume the "
384
+ f"entire box height ({int(box.height)} EMU)"
385
+ )
386
+ heights = _apportion(span, ratios, total)
387
+ out2: list[BBox] = []
388
+ cursor = int(box.top)
389
+ for h in heights:
390
+ out2.append(BBox(box.left, Emu(cursor), box.width, Emu(h)))
391
+ cursor += h + int(gap)
392
+ return out2
393
+ else:
394
+ raise ValueError(f"axis must be 'h' or 'v'; got {axis!r}")
395
+
396
+
397
+ def _apportion(span: int, ratios: Sequence[float], total: float) -> list[int]:
398
+ """Distribute *span* across *ratios* so the result sums to exactly *span*.
399
+
400
+ Rounding each segment independently with ``int(round(span * r / total))``
401
+ can drift up or down by a few EMU per split, which then accumulates across
402
+ nested ``BBox.grid`` calls and silently breaks layouts that assume the
403
+ cells partition the box exactly. Tracking the remaining span / remaining
404
+ ratio after each emission keeps the running total exact.
405
+ """
406
+ widths: list[int] = []
407
+ rem_span = int(span)
408
+ rem_total = float(total)
409
+ for i, r in enumerate(ratios):
410
+ if i == len(ratios) - 1:
411
+ widths.append(rem_span)
412
+ break
413
+ if rem_total <= 0:
414
+ widths.append(0)
415
+ continue
416
+ w = int(round(rem_span * float(r) / rem_total))
417
+ widths.append(w)
418
+ rem_span -= w
419
+ rem_total -= float(r)
420
+ return widths
pptx2/inherit.py ADDED
@@ -0,0 +1,109 @@
1
+ """Theme-aware inheritance helpers for color/effect getters.
2
+
3
+ Phase 1 made every color getter non-mutating: reading a property that
4
+ isn't explicitly set returns ``None`` and leaves the XML untouched.
5
+ That preserves theme inheritance — but it also means callers who want
6
+ the *effective* color (the one PowerPoint will actually render) have to
7
+ walk the style hierarchy themselves.
8
+
9
+ This module provides the read-only resolver that closes that gap. The
10
+ public surface is a single function::
11
+
12
+ from pptx2.inherit import resolve_color
13
+ rgb = resolve_color(font.color, theme=prs.theme) # RGBColor or None
14
+
15
+ Scope (intentionally focused):
16
+
17
+ * ``MSO_COLOR_TYPE.RGB`` — returns the explicit ``RGBColor``, applying
18
+ the ``brightness`` adjustment if any.
19
+ * ``MSO_COLOR_TYPE.SCHEME`` — looks the theme color up via
20
+ ``theme.colors[theme_color]`` and applies the brightness adjustment.
21
+ * ``None`` (no explicit color) — returns ``None`` without trying to
22
+ walk slide → layout → master placeholder inheritance. Implementing
23
+ the full placeholder walk is a substantial follow-up; this resolver
24
+ intentionally stops short of it so callers get a deterministic,
25
+ side-effect-free answer.
26
+
27
+ Brightness handling matches ``ColorFormat.brightness``: a value in
28
+ ``[-1.0, 1.0]`` where negative numbers darken the resolved RGB
29
+ proportionally and positive numbers lighten it. The math mirrors
30
+ PowerPoint's ``lumMod``/``lumOff`` / ``tint``/``shade`` model closely
31
+ enough to be useful for design-system code that needs to render
32
+ mock-ups outside PowerPoint (e.g. the lint contrast check planned in
33
+ Phase 2).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import TYPE_CHECKING, Optional
39
+
40
+ from pptx2.dml.color import ColorFormat, RGBColor
41
+ from pptx2.enum.dml import MSO_COLOR_TYPE
42
+
43
+ if TYPE_CHECKING:
44
+ from pptx2.theme import Theme
45
+
46
+
47
+ def resolve_color(color_format: ColorFormat, *, theme: "Theme | None" = None) -> Optional[RGBColor]:
48
+ """Return the effective `RGBColor` for `color_format`, or ``None``.
49
+
50
+ `color_format` is any :class:`~pptx2.dml.color.ColorFormat` (including
51
+ the lazy proxy returned by ``Font.color`` / ``LineFormat.color``).
52
+
53
+ `theme` is required to resolve theme colors; pass
54
+ ``presentation.theme``. When a scheme color cannot be resolved
55
+ (e.g. ``theme=None`` or the slot is unmapped) this function returns
56
+ ``None`` rather than raising, so it composes safely with downstream
57
+ callers that fall back to a hard-coded default.
58
+ """
59
+ color_type = color_format.type
60
+ if color_type == MSO_COLOR_TYPE.RGB:
61
+ rgb = color_format.rgb
62
+ if rgb is None:
63
+ return None
64
+ return _apply_brightness(rgb, color_format.brightness)
65
+ if color_type == MSO_COLOR_TYPE.SCHEME:
66
+ if theme is None:
67
+ return None
68
+ try:
69
+ theme_color = color_format.theme_color
70
+ except AttributeError:
71
+ return None
72
+ try:
73
+ base = theme.colors[theme_color]
74
+ except (KeyError, TypeError):
75
+ return None
76
+ return _apply_brightness(base, color_format.brightness)
77
+ return None
78
+
79
+
80
+ def _apply_brightness(rgb: RGBColor, brightness: float | None) -> RGBColor:
81
+ """Lighten/darken `rgb` toward white/black per PowerPoint's brightness model.
82
+
83
+ A `brightness` of 0 (or `None`) is a no-op. Positive values tint
84
+ toward white; negative values shade toward black. The blend ratio
85
+ is the absolute value of `brightness`, capped at 1.0.
86
+ """
87
+ if not brightness:
88
+ return rgb
89
+ delta = max(-1.0, min(1.0, brightness))
90
+ if delta > 0:
91
+ return _blend(rgb, _WHITE, delta)
92
+ return _blend(rgb, _BLACK, -delta)
93
+
94
+
95
+ def _blend(a: RGBColor, b: RGBColor, t: float) -> RGBColor:
96
+ """Linear blend from `a` toward `b` by ratio `t` in ``[0, 1]``."""
97
+ return RGBColor(
98
+ _u8(a[0] + (b[0] - a[0]) * t),
99
+ _u8(a[1] + (b[1] - a[1]) * t),
100
+ _u8(a[2] + (b[2] - a[2]) * t),
101
+ )
102
+
103
+
104
+ def _u8(value: float) -> int:
105
+ return max(0, min(255, int(round(value))))
106
+
107
+
108
+ _WHITE = RGBColor(0xFF, 0xFF, 0xFF)
109
+ _BLACK = RGBColor(0x00, 0x00, 0x00)