From e66d0f2714eb98bfdd637154f35ec3886973b015 Mon Sep 17 00:00:00 2001 From: Spandan Date: Wed, 10 Sep 2025 20:56:40 +0530 Subject: [PATCH 1/2] support opacity on export --- src/pptx/dml/color.py | 60 ++++++++++++++++++++++++++++++++++++++ src/pptx/dml/fill.py | 40 +++++++++++++++++++++++++ src/pptx/oxml/__init__.py | 4 +++ src/pptx/oxml/dml/color.py | 47 ++++++++++++++++++++++++++++- tests/oxml/unitdata/dml.py | 12 ++++++++ 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/pptx/dml/color.py b/src/pptx/dml/color.py index 54155823..f9ba5fc8 100644 --- a/src/pptx/dml/color.py +++ b/src/pptx/dml/color.py @@ -38,6 +38,20 @@ def brightness(self, value): self._validate_brightness_value(value) self._color.brightness = value + @property + def transparency(self): + """ + Read/write float value between 0.0 and 1.0 indicating the transparency + of this color, e.g. 0.0 is completely opaque and 1.0 is completely + transparent. 0.5 is 50% transparent. + """ + return self._color.transparency + + @transparency.setter + def transparency(self, value): + self._validate_transparency_value(value) + self._color.transparency = value + @classmethod def from_colorchoice_parent(cls, eg_colorChoice_parent): xClr = eg_colorChoice_parent.eg_colorChoice @@ -106,6 +120,16 @@ def _validate_brightness_value(self, value): ) raise ValueError(msg) + def _validate_transparency_value(self, value): + if value < 0.0 or value > 1.0: + raise ValueError("transparency must be number in range 0.0 to 1.0") + if isinstance(self._color, _NoneColor): + msg = ( + "can't set transparency when color.type is None. Set color.rgb" + " or .theme_color first." + ) + raise ValueError(msg) + class _Color(object): """ @@ -153,6 +177,42 @@ def brightness(self, value): else: self._xClr.clear_lum() + @property + def transparency(self): + """ + Read/write float value between 0.0 and 1.0 indicating the transparency + of this color. 0.0 is completely opaque, 1.0 is completely transparent. + """ + if self._xClr is None: + # NoneColor has no transparency + return 0.0 + alpha = self._xClr.alpha + if alpha is not None: + # alpha.val is in range 0.0-1.0, where 1.0 = 100% opaque + # transparency is 1.0 - alpha.val + return 1.0 - alpha.val + # no alpha element means fully opaque (0% transparent) + return 0.0 + + @transparency.setter + def transparency(self, value): + if self._xClr is None: + # NoneColor cannot have transparency set + msg = ( + "can't set transparency when color.type is None. Set color.rgb" + " or .theme_color first." + ) + raise ValueError(msg) + if value == 0.0: + # fully opaque, remove alpha element + self._xClr.clear_alpha() + else: + # convert transparency (0.0-1.0) to alpha value (1.0-0.0) + # alpha = 1.0 - transparency + alpha_val = 1.0 - value + self._xClr.clear_alpha() + self._xClr.add_alpha(alpha_val) + @property def color_type(self): # pragma: no cover tmpl = ".color_type property must be implemented on %s" diff --git a/src/pptx/dml/fill.py b/src/pptx/dml/fill.py index 8212af9e..28f8e9e5 100644 --- a/src/pptx/dml/fill.py +++ b/src/pptx/dml/fill.py @@ -70,6 +70,22 @@ def fore_color(self): """ return self._fill.fore_color + @property + def transparency(self): + """ + Read/write float value between 0.0 and 1.0 indicating the transparency + of this fill, e.g. 0.0 is completely opaque and 1.0 is completely + transparent. 0.5 is 50% transparent. + + This property is only applicable to solid fills. For other fill types, + accessing this property will raise a TypeError. + """ + return self._fill.transparency + + @transparency.setter + def transparency(self, value): + self._fill.transparency = value + def gradient(self): """Sets the fill type to gradient. @@ -205,6 +221,18 @@ def pattern(self): tmpl = "fill type %s has no pattern, call .patterned() first" raise TypeError(tmpl % self.__class__.__name__) + @property + def transparency(self): + """Raise TypeError for fills that do not override this property.""" + tmpl = "fill type %s has no transparency, call .solid() first" + raise TypeError(tmpl % self.__class__.__name__) + + @transparency.setter + def transparency(self, value): + """Raise TypeError for fills that do not override this property.""" + tmpl = "fill type %s has no transparency, call .solid() first" + raise TypeError(tmpl % self.__class__.__name__) + @property def type(self) -> MSO_FILL_TYPE: # pragma: no cover raise NotImplementedError( @@ -343,6 +371,18 @@ def fore_color(self): """Return |ColorFormat| object controlling fill color.""" return ColorFormat.from_colorchoice_parent(self._solidFill) + @property + def transparency(self): + """ + Read/write float value between 0.0 and 1.0 indicating the transparency + of this solid fill. 0.0 is completely opaque, 1.0 is completely transparent. + """ + return self.fore_color.transparency + + @transparency.setter + def transparency(self, value): + self.fore_color.transparency = value + @property def type(self): return MSO_FILL.SOLID diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 21afaa92..57333003 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -221,6 +221,7 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): CT_Color, CT_HslColor, CT_Percentage, + CT_PositiveFixedPercentage, CT_PresetColor, CT_SchemeColor, CT_ScRgbColor, @@ -228,6 +229,9 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): CT_SystemColor, ) +register_element_cls("a:alpha", CT_PositiveFixedPercentage) +register_element_cls("a:alphaOff", CT_PositiveFixedPercentage) +register_element_cls("a:alphaMod", CT_PositiveFixedPercentage) register_element_cls("a:bgClr", CT_Color) register_element_cls("a:fgClr", CT_Color) register_element_cls("a:hslClr", CT_HslColor) diff --git a/src/pptx/oxml/dml/color.py b/src/pptx/oxml/dml/color.py index dfce90aa..b52b815b 100644 --- a/src/pptx/oxml/dml/color.py +++ b/src/pptx/oxml/dml/color.py @@ -3,7 +3,7 @@ from __future__ import annotations from pptx.enum.dml import MSO_THEME_COLOR -from pptx.oxml.simpletypes import ST_HexColorRGB, ST_Percentage +from pptx.oxml.simpletypes import ST_HexColorRGB, ST_Percentage, ST_PositiveFixedPercentage from pptx.oxml.xmlchemy import ( BaseOxmlElement, Choice, @@ -20,6 +20,9 @@ class _BaseColorElement(BaseOxmlElement): lumMod = ZeroOrOne("a:lumMod") lumOff = ZeroOrOne("a:lumOff") + alpha = ZeroOrOne("a:alpha") + alphaOff = ZeroOrOne("a:alphaOff") + alphaMod = ZeroOrOne("a:alphaMod") def add_lumMod(self, value): """ @@ -37,6 +40,30 @@ def add_lumOff(self, value): lumOff.val = value return lumOff + def add_alpha(self, value): + """ + Return a newly added child element. + """ + alpha = self._add_alpha() + alpha.val = value + return alpha + + def add_alphaOff(self, value): + """ + Return a newly added child element. + """ + alphaOff = self._add_alphaOff() + alphaOff.val = value + return alphaOff + + def add_alphaMod(self, value): + """ + Return a newly added child element. + """ + alphaMod = self._add_alphaMod() + alphaMod.val = value + return alphaMod + def clear_lum(self): """ Return self after removing any and child @@ -46,6 +73,16 @@ def clear_lum(self): self._remove_lumOff() return self + def clear_alpha(self): + """ + Return self after removing any , and child + elements. + """ + self._remove_alpha() + self._remove_alphaOff() + self._remove_alphaMod() + return self + class CT_Color(BaseOxmlElement): """Custom element class for `a:fgClr`, `a:bgClr` and perhaps others.""" @@ -77,6 +114,14 @@ class CT_Percentage(BaseOxmlElement): val = RequiredAttribute("val", ST_Percentage) +class CT_PositiveFixedPercentage(BaseOxmlElement): + """ + Custom element class for , and elements. + """ + + val = RequiredAttribute("val", ST_PositiveFixedPercentage) + + class CT_PresetColor(_BaseColorElement): """ Custom element class for element. diff --git a/tests/oxml/unitdata/dml.py b/tests/oxml/unitdata/dml.py index 8c716ab8..0e9307fe 100644 --- a/tests/oxml/unitdata/dml.py +++ b/tests/oxml/unitdata/dml.py @@ -70,6 +70,18 @@ def a_lumOff(): return CT_PercentageBuilder("a:lumOff") +def a_alpha(): + return CT_PercentageBuilder("a:alpha") + + +def a_alphaOff(): + return CT_PercentageBuilder("a:alphaOff") + + +def a_alphaMod(): + return CT_PercentageBuilder("a:alphaMod") + + def a_prstClr(): return CT_PresetColorBuilder() From 35e31d0f03785111496b5a88655bc98bac6183e9 Mon Sep 17 00:00:00 2001 From: Spandan Date: Thu, 11 Sep 2025 13:52:29 +0530 Subject: [PATCH 2/2] add tests --- tests/dml/test_transparency.py | 355 +++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/dml/test_transparency.py diff --git a/tests/dml/test_transparency.py b/tests/dml/test_transparency.py new file mode 100644 index 00000000..a9b3a95c --- /dev/null +++ b/tests/dml/test_transparency.py @@ -0,0 +1,355 @@ +"""Unit-test suite for transparency functionality in `pptx.dml` module.""" + +from __future__ import annotations + +import pytest + +from pptx.dml.color import ColorFormat, RGBColor +from pptx.dml.fill import FillFormat, _SolidFill +from pptx.enum.dml import MSO_COLOR_TYPE, MSO_FILL + +from ..oxml.unitdata.dml import ( + a_alpha, + a_solidFill, + an_srgbClr, + a_schemeClr, +) +from ..unitutil.cxml import element + + +class DescribeColorFormatTransparency(object): + """Unit-test suite for ColorFormat transparency property.""" + + def it_knows_its_transparency_value(self, transparency_get_fixture): + color_format, expected_transparency = transparency_get_fixture + assert color_format.transparency == expected_transparency + + def it_can_set_its_transparency_value(self, transparency_set_fixture): + color_format, transparency, expected_xml = transparency_set_fixture + color_format.transparency = transparency + assert color_format._xFill.xml == expected_xml + + def it_raises_on_transparency_get_for_NoneColor(self, _NoneColor_color_format): + # This should not raise - NoneColor should return 0.0 transparency + transparency = _NoneColor_color_format.transparency + assert transparency == 0.0 + + def it_raises_on_transparency_set_for_NoneColor(self, _NoneColor_color_format): + with pytest.raises(ValueError): + _NoneColor_color_format.transparency = 0.5 + + def it_raises_on_assign_invalid_transparency_value(self, rgb_color_format): + color_format = rgb_color_format + with pytest.raises(ValueError): + color_format.transparency = 1.1 + with pytest.raises(ValueError): + color_format.transparency = -0.1 + + def it_can_set_transparency_to_zero_removes_alpha_element(self, rgb_color_format): + """Setting transparency to 0.0 should remove the alpha element.""" + color_format = rgb_color_format + # First set some transparency + color_format.transparency = 0.5 + assert color_format._color._xClr.alpha is not None + + # Then set to 0.0 - should remove alpha element + color_format.transparency = 0.0 + assert color_format._color._xClr.alpha is None + assert color_format.transparency == 0.0 + + def it_can_set_transparency_to_one_creates_zero_alpha(self, rgb_color_format): + """Setting transparency to 1.0 should create alpha element with val=0.""" + color_format = rgb_color_format + color_format.transparency = 1.0 + + alpha_element = color_format._color._xClr.alpha + assert alpha_element is not None + assert alpha_element.val == 0.0 + assert color_format.transparency == 1.0 + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + # no alpha element - fully opaque + (lambda: an_srgbClr().with_val("FF0000"), 0.0), + # alpha = 100000 (100% opaque) - 0% transparent + (lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(100000)), 0.0), + # alpha = 50000 (50% opaque) - 50% transparent + (lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(50000)), 0.5), + # alpha = 0 (0% opaque) - 100% transparent + (lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(0)), 1.0), + ] + ) + def transparency_get_fixture(self, request): + xClr_bldr_fn, expected_transparency = request.param + xClr_bldr = xClr_bldr_fn() + solidFill = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + color_format = ColorFormat.from_colorchoice_parent(solidFill) + return color_format, expected_transparency + + @pytest.fixture( + params=[ + # Set to 0.0 (fully opaque) - should remove alpha element + (lambda: an_srgbClr().with_val("FF0000"), 0.0, + lambda: an_srgbClr().with_val("FF0000")), + # Set to 0.5 (50% transparent) - should add alpha=50000 + (lambda: an_srgbClr().with_val("FF0000"), 0.5, + lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(50000))), + # Set to 1.0 (fully transparent) - should add alpha=0 + (lambda: an_srgbClr().with_val("FF0000"), 1.0, + lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(0))), + # Set from existing alpha to different value + (lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(75000)), 0.3, + lambda: an_srgbClr().with_val("FF0000").with_child( + a_alpha().with_val(70000))), + ] + ) + def transparency_set_fixture(self, request): + xClr_bldr_fn, transparency, expected_xClr_bldr_fn = request.param + + xClr_bldr = xClr_bldr_fn() + solidFill = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + color_format = ColorFormat.from_colorchoice_parent(solidFill) + + expected_xClr_bldr = expected_xClr_bldr_fn() + expected_xml = a_solidFill().with_nsdecls().with_child(expected_xClr_bldr).xml() + + return color_format, transparency, expected_xml + + @pytest.fixture + def rgb_color_format(self): + solidFill = a_solidFill().with_nsdecls().with_child( + an_srgbClr().with_val("FF0000") + ).element + return ColorFormat.from_colorchoice_parent(solidFill) + + @pytest.fixture + def _NoneColor_color_format(self): + solidFill = a_solidFill().with_nsdecls().element + return ColorFormat.from_colorchoice_parent(solidFill) + + # Additional validation tests + def it_validates_transparency_value_range(self): + """Test that _validate_transparency_value works correctly.""" + from pptx.dml.color import ColorFormat + + # Create a dummy ColorFormat instance for testing validation + xClr_bldr = an_srgbClr().with_val("FF0000") + solidFill = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + color_format = ColorFormat.from_colorchoice_parent(solidFill) + + # Valid values should not raise + color_format._validate_transparency_value(0.0) + color_format._validate_transparency_value(0.5) + color_format._validate_transparency_value(1.0) + + # Invalid values should raise ValueError + with pytest.raises(ValueError, match="transparency must be number in range 0.0 to 1.0"): + color_format._validate_transparency_value(-0.1) + with pytest.raises(ValueError, match="transparency must be number in range 0.0 to 1.0"): + color_format._validate_transparency_value(1.1) + with pytest.raises(ValueError, match="transparency must be number in range 0.0 to 1.0"): + color_format._validate_transparency_value(2.0) + + +class DescribeFillFormatTransparency(object): + """Unit-test suite for FillFormat transparency property.""" + + def it_delegates_transparency_to_solid_fill_object(self): + """Test that FillFormat delegates transparency to its _SolidFill object.""" + # Create a _SolidFill directly + xClr_bldr = an_srgbClr().with_val("FF0000") + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + solid_fill = _SolidFill(solidFill_elm) + + # Create a FillFormat that wraps our _SolidFill + from pptx.dml.fill import FillFormat + + # Create a simple mock parent + class MockParent: + def __init__(self): + self.eg_fillProperties = solidFill_elm + + parent_mock = MockParent() + fill_format = FillFormat(parent_mock, solid_fill) + + # Set transparency through FillFormat + fill_format.transparency = 0.3 + + # Verify it's delegated to the underlying solid fill + assert abs(fill_format._fill.transparency - 0.3) < 0.001 + assert abs(fill_format.transparency - 0.3) < 0.001 + + def it_raises_on_transparency_access_for_non_solid_fills(self): + """Test that non-solid fills raise TypeError on transparency access.""" + from pptx.dml.fill import FillFormat, _NoFill + + # Create a FillFormat with NoFill + class MockParent: + def __init__(self): + self.eg_fillProperties = None + + parent_mock = MockParent() + no_fill = _NoFill(None) + fill_format = FillFormat(parent_mock, no_fill) + + with pytest.raises(TypeError, match="fill type .* has no transparency"): + fill_format.transparency + + def it_raises_on_transparency_set_for_non_solid_fills(self): + """Test that non-solid fills raise TypeError on transparency set.""" + from pptx.dml.fill import FillFormat, _NoFill + + # Create a FillFormat with NoFill + class MockParent: + def __init__(self): + self.eg_fillProperties = None + + parent_mock = MockParent() + no_fill = _NoFill(None) + fill_format = FillFormat(parent_mock, no_fill) + + with pytest.raises(TypeError, match="fill type .* has no transparency"): + fill_format.transparency = 0.5 + + +class DescribeSolidFillTransparency(object): + """Unit-test suite for _SolidFill transparency property.""" + + def it_provides_access_to_transparency(self, solid_fill_transparency_fixture): + solid_fill, expected_transparency = solid_fill_transparency_fixture + assert abs(solid_fill.transparency - expected_transparency) < 0.001 + + def it_can_set_transparency(self, solid_fill_transparency_set_fixture): + solid_fill, transparency = solid_fill_transparency_set_fixture + solid_fill.transparency = transparency + assert abs(solid_fill.transparency - transparency) < 0.001 + + def it_delegates_transparency_to_fore_color(self, solid_fill_obj): + """Test that _SolidFill delegates transparency to its fore_color.""" + solid_fill = solid_fill_obj + + # Set transparency through _SolidFill + solid_fill.transparency = 0.6 + + # Verify it's delegated to fore_color + assert solid_fill.fore_color.transparency == 0.6 + assert solid_fill.transparency == 0.6 + + def it_has_correct_fill_type(self, solid_fill_obj): + """Test that _SolidFill reports correct fill type.""" + solid_fill = solid_fill_obj + assert solid_fill.type == MSO_FILL.SOLID + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + # No alpha element - fully opaque + (lambda: an_srgbClr().with_val("00FF00"), 0.0), + # Alpha = 80000 (80% opaque) - 20% transparent + (lambda: an_srgbClr().with_val("00FF00").with_child( + a_alpha().with_val(80000)), 0.2), + # Alpha = 30000 (30% opaque) - 70% transparent + (lambda: an_srgbClr().with_val("00FF00").with_child( + a_alpha().with_val(30000)), 0.7), + ] + ) + def solid_fill_transparency_fixture(self, request): + xClr_bldr_fn, expected_transparency = request.param + xClr_bldr = xClr_bldr_fn() + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + solid_fill = _SolidFill(solidFill_elm) + return solid_fill, expected_transparency + + @pytest.fixture( + params=[0.0, 0.1, 0.33, 0.67, 0.9, 1.0] + ) + def solid_fill_transparency_set_fixture(self, request): + transparency = request.param + xClr_bldr = an_srgbClr().with_val("00FF00") + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + solid_fill = _SolidFill(solidFill_elm) + return solid_fill, transparency + + @pytest.fixture + def solid_fill_obj(self): + """Create a _SolidFill object for testing.""" + xClr_bldr = an_srgbClr().with_val("0000FF") + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + return _SolidFill(solidFill_elm) + + +class DescribeTransparencyIntegration(object): + """Integration tests for transparency across the entire stack.""" + + def it_works_end_to_end_with_solid_fill_and_color_format(self): + """Test transparency from ColorFormat through _SolidFill.""" + # Create a complete fill hierarchy using _SolidFill directly + xClr_bldr = an_srgbClr().with_val("FF00FF") + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + solid_fill = _SolidFill(solidFill_elm) + + # Test setting transparency at _SolidFill level + solid_fill.transparency = 0.4 + + # Verify it propagates through all levels + assert abs(solid_fill.transparency - 0.4) < 0.001 + assert abs(solid_fill.fore_color.transparency - 0.4) < 0.001 + + # Verify the underlying XML has the alpha element + alpha_elm = solid_fill.fore_color._color._xClr.alpha + assert alpha_elm is not None + assert abs(alpha_elm.val - 0.6) < 0.001 # 1.0 - 0.4 = 0.6 (60% opaque) + + def it_handles_transparency_removal_correctly(self): + """Test that setting transparency to 0.0 removes alpha element.""" + xClr_bldr = an_srgbClr().with_val("FFFF00").with_child(a_alpha().with_val(40000)) + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + solid_fill = _SolidFill(solidFill_elm) + + # Verify initial transparency (40000/100000 = 0.4 opaque = 0.6 transparent) + assert abs(solid_fill.transparency - 0.6) < 0.001 + assert solid_fill.fore_color._color._xClr.alpha is not None + + # Set transparency to 0.0 + solid_fill.transparency = 0.0 + + # Verify alpha element is removed + assert solid_fill.transparency == 0.0 + assert solid_fill.fore_color._color._xClr.alpha is None + + def it_handles_color_format_transparency_directly(self): + """Test ColorFormat transparency manipulation directly.""" + xClr_bldr = an_srgbClr().with_val("00FFFF") + solidFill_elm = a_solidFill().with_nsdecls().with_child(xClr_bldr).element + + color_format = ColorFormat.from_colorchoice_parent(solidFill_elm) + + # Test setting various transparency values + test_values = [0.0, 0.25, 0.5, 0.75, 1.0] + for transparency in test_values: + color_format.transparency = transparency + assert abs(color_format.transparency - transparency) < 0.001 + + if transparency == 0.0: + # No alpha element for fully opaque + assert color_format._color._xClr.alpha is None + else: + # Alpha element should exist with correct value + alpha_elm = color_format._color._xClr.alpha + assert alpha_elm is not None + expected_alpha = 1.0 - transparency + assert abs(alpha_elm.val - expected_alpha) < 0.001 \ No newline at end of file