Skip to content

Commit c675b6d

Browse files
committed
test(dml): pin gradient-stop transparency behavior with explicit coverage
PR #30 added ColorFormat.transparency, which flows through to gradient stops transitively because _GradientStop.color returns a ColorFormat. That worked from day one, but no test asserted it — the behavior was load-bearing for issue #17 follow-up (hmeine, post-v1.2.0 release) but only covered by accident of architecture. This change pins it. The trigger was hmeine's comment on issue #17 asking whether gradient stops also expose alpha. Empirically `stop.color.transparency = 0.5` already worked on master and round-tripped to <a:alpha val="50000"/> under the color choice — but only because PR #30's surface naturally inherits. A future refactor of _GradientStop (e.g. switching to a ProxyMixin without ColorFormat-from-parent semantics) could silently regress it. These tests block that. Surface: - tests/dml/test_transparency.py: +11 new tests across two describe classes — DescribeGradientStopTransparency (9 unit tests): reads zero when no alpha, reads alpha under srgbClr, reads alpha under schemeClr, writes alpha under srgbClr, writes alpha under schemeClr (the OOXML placement invariant — <a:alpha> goes inside the color choice element, NOT directly on <a:gs>), clears alpha on zero, rejects out-of-range, rejects set on NoneColor stop, and exposes the property through the _GradientStop proxy. DescribeGradientStopTransparencyRoundTrip (2 integration tests): full Presentation -> fill.gradient() -> set transparency -> save/reload -> assert transparency preserved; and the alpha placement invariant verified post-roundtrip. No production code changed. The transparency surface itself ships in PR #30 / commit 253dbc8. This is belt-and-suspenders coverage. Tests: 3983 passed (+11 new), 1130 behave scenarios all green. Lint: ruff check + ruff format clean. Refs #17.
1 parent ee53fd0 commit c675b6d

1 file changed

Lines changed: 180 additions & 1 deletion

File tree

tests/dml/test_transparency.py

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
from __future__ import annotations
44

5+
import io
6+
57
import pytest
68

9+
from pptx import Presentation
710
from pptx.dml.color import ColorFormat
8-
from pptx.dml.fill import FillFormat, _NoFill, _SolidFill
11+
from pptx.dml.fill import FillFormat, _GradientStop, _NoFill, _SolidFill
912
from pptx.enum.dml import MSO_FILL
13+
from pptx.enum.shapes import MSO_SHAPE
14+
from pptx.oxml import parse_xml
15+
from pptx.oxml.ns import nsdecls, qn
16+
from pptx.util import Inches
1017

1118
from ..oxml.unitdata.dml import a_alpha, a_solidFill, an_srgbClr
1219

@@ -278,3 +285,175 @@ def it_handles_color_format_transparency_directly(self):
278285
assert alpha_elm is not None
279286
expected_alpha = 1.0 - transparency
280287
assert abs(alpha_elm.val - expected_alpha) < 0.001
288+
289+
290+
class DescribeGradientStopTransparency(object):
291+
"""Unit-test suite for transparency on a single gradient stop.
292+
293+
Covers issue #17 follow-up: `_GradientStop.color` returns a `ColorFormat`,
294+
so the PR #30 transparency surface should flow through transitively. These
295+
tests pin the behavior in so a future refactor of either side can't
296+
silently regress it.
297+
"""
298+
299+
def it_reads_zero_transparency_when_stop_has_no_alpha(self):
300+
gs = self._gs_with_srgb("FF0000")
301+
color_format = ColorFormat.from_colorchoice_parent(gs)
302+
303+
assert color_format.transparency == 0.0
304+
305+
def it_reads_transparency_from_alpha_under_srgbClr(self):
306+
gs = self._gs_with_srgb("FF0000", alpha_val=50000)
307+
color_format = ColorFormat.from_colorchoice_parent(gs)
308+
309+
assert abs(color_format.transparency - 0.5) < 0.001
310+
311+
def it_reads_transparency_from_alpha_under_schemeClr(self):
312+
gs = self._gs_with_scheme("accent1", alpha_val=30000)
313+
color_format = ColorFormat.from_colorchoice_parent(gs)
314+
315+
assert abs(color_format.transparency - 0.7) < 0.001
316+
317+
def it_writes_alpha_under_srgbClr_when_setting_transparency(self):
318+
gs = self._gs_with_srgb("FF0000")
319+
color_format = ColorFormat.from_colorchoice_parent(gs)
320+
321+
color_format.transparency = 0.5
322+
323+
srgbClr = gs.find(qn("a:srgbClr"))
324+
alpha = srgbClr.find(qn("a:alpha"))
325+
assert alpha is not None, "alpha must be placed under <a:srgbClr>, not <a:gs>"
326+
assert abs(alpha.val - 0.5) < 0.001
327+
328+
def it_writes_alpha_under_schemeClr_when_setting_transparency(self):
329+
gs = self._gs_with_scheme("accent1")
330+
color_format = ColorFormat.from_colorchoice_parent(gs)
331+
332+
color_format.transparency = 0.6
333+
334+
schemeClr = gs.find(qn("a:schemeClr"))
335+
alpha = schemeClr.find(qn("a:alpha"))
336+
assert alpha is not None, "alpha must be placed under <a:schemeClr>, not <a:gs>"
337+
assert abs(alpha.val - 0.4) < 0.001
338+
339+
def it_clears_alpha_when_setting_transparency_to_zero(self):
340+
gs = self._gs_with_srgb("FF0000", alpha_val=40000)
341+
color_format = ColorFormat.from_colorchoice_parent(gs)
342+
343+
assert abs(color_format.transparency - 0.6) < 0.001
344+
345+
color_format.transparency = 0.0
346+
347+
srgbClr = gs.find(qn("a:srgbClr"))
348+
assert srgbClr.find(qn("a:alpha")) is None
349+
assert color_format.transparency == 0.0
350+
351+
def it_raises_on_out_of_range_value(self):
352+
gs = self._gs_with_srgb("FF0000")
353+
color_format = ColorFormat.from_colorchoice_parent(gs)
354+
355+
with pytest.raises(ValueError, match="transparency must be number in range 0.0 to 1.0"):
356+
color_format.transparency = 1.1
357+
with pytest.raises(ValueError, match="transparency must be number in range 0.0 to 1.0"):
358+
color_format.transparency = -0.1
359+
360+
def it_raises_on_set_when_stop_has_no_color_choice(self):
361+
gs = parse_xml('<a:gs %s pos="50000"/>' % nsdecls("a"))
362+
color_format = ColorFormat.from_colorchoice_parent(gs)
363+
364+
with pytest.raises(ValueError, match="can't set transparency when color.type is None"):
365+
color_format.transparency = 0.5
366+
367+
def it_exposes_transparency_through_GradientStop_color(self):
368+
gs = self._gs_with_srgb("FF0000")
369+
stop = _GradientStop(gs)
370+
371+
stop.color.transparency = 0.25
372+
373+
assert abs(stop.color.transparency - 0.25) < 0.001
374+
srgbClr = gs.find(qn("a:srgbClr"))
375+
alpha = srgbClr.find(qn("a:alpha"))
376+
assert alpha is not None
377+
assert abs(alpha.val - 0.75) < 0.001
378+
379+
# helpers --------------------------------------------------------
380+
381+
@staticmethod
382+
def _gs_with_srgb(val, alpha_val=None):
383+
alpha = f'<a:alpha val="{alpha_val}"/>' if alpha_val is not None else ""
384+
xml = ('<a:gs %s pos="50000"><a:srgbClr val="%s">%s</a:srgbClr></a:gs>') % (
385+
nsdecls("a"),
386+
val,
387+
alpha,
388+
)
389+
return parse_xml(xml)
390+
391+
@staticmethod
392+
def _gs_with_scheme(val, alpha_val=None):
393+
alpha = f'<a:alpha val="{alpha_val}"/>' if alpha_val is not None else ""
394+
xml = ('<a:gs %s pos="50000"><a:schemeClr val="%s">%s</a:schemeClr></a:gs>') % (
395+
nsdecls("a"),
396+
val,
397+
alpha,
398+
)
399+
return parse_xml(xml)
400+
401+
402+
class DescribeGradientStopTransparencyRoundTrip(object):
403+
"""End-to-end round-trip for gradient stop transparency.
404+
405+
The pure XML tests above prove the API and OOXML placement. This class
406+
proves a real Presentation survives save+reopen with transparency intact
407+
on its gradient stops — the closest thing to the actual user surface
408+
short of a UAT visual check.
409+
"""
410+
411+
def it_round_trips_transparency_on_default_gradient_stops(self):
412+
prs = Presentation()
413+
slide = prs.slides.add_slide(prs.slide_layouts[5])
414+
shape = slide.shapes.add_shape(
415+
MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(3), Inches(2)
416+
)
417+
shape.fill.gradient()
418+
stops = shape.fill.gradient_stops
419+
stops[0].color.transparency = 0.0
420+
stops[1].color.transparency = 0.5
421+
422+
buf = io.BytesIO()
423+
prs.save(buf)
424+
buf.seek(0)
425+
426+
prs2 = Presentation(buf)
427+
stops2 = prs2.slides[0].shapes[1].fill.gradient_stops
428+
assert stops2[0].color.transparency == 0.0
429+
assert abs(stops2[1].color.transparency - 0.5) < 0.001
430+
431+
def it_persists_alpha_inside_color_choice_not_on_gs(self):
432+
prs = Presentation()
433+
slide = prs.slides.add_slide(prs.slide_layouts[5])
434+
shape = slide.shapes.add_shape(
435+
MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
436+
)
437+
shape.fill.gradient()
438+
shape.fill.gradient_stops[1].color.transparency = 0.4
439+
440+
buf = io.BytesIO()
441+
prs.save(buf)
442+
buf.seek(0)
443+
prs2 = Presentation(buf)
444+
shape2 = prs2.slides[0].shapes[1]
445+
446+
gs_list = shape2.fill._xPr.findall(
447+
".//" + qn("a:gradFill") + "/" + qn("a:gsLst") + "/" + qn("a:gs")
448+
)
449+
assert len(gs_list) == 2
450+
gs_with_alpha = gs_list[1]
451+
# alpha must live under the color choice element, NOT directly on <a:gs>
452+
assert gs_with_alpha.find(qn("a:alpha")) is None
453+
color_choice = gs_with_alpha.find(qn("a:schemeClr"))
454+
if color_choice is None:
455+
color_choice = gs_with_alpha.find(qn("a:srgbClr"))
456+
assert color_choice is not None
457+
alpha = color_choice.find(qn("a:alpha"))
458+
assert alpha is not None
459+
assert abs(alpha.val - 0.6) < 0.001

0 commit comments

Comments
 (0)