Skip to content

Commit 02af520

Browse files
committed
refactor: replace RGBColor with Color type in color handling
1 parent 0bc2b11 commit 02af520

File tree

8 files changed

+141
-70
lines changed

8 files changed

+141
-70
lines changed

examples/formatted_text.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def functional_text(
2424
text: str,
2525
bold: bool = False,
2626
italic: bool = False,
27-
color: tppt.types.RGBColor | tppt.types.LiteralColor | None = None,
27+
color: tppt.types.Color | tppt.types.LiteralColor | None = None,
2828
) -> tppt.pptx.Text:
2929
run = text_obj.text_frame.add_paragraph().add_run()
3030
run.text = text

src/tppt/exception.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import abstractmethod
2-
from typing import TYPE_CHECKING, Any
2+
from typing import TYPE_CHECKING, Any, Literal
33

44
if TYPE_CHECKING:
55
from pptx.slide import SlideLayouts as PptxSlideLayouts
@@ -31,6 +31,17 @@ def message(self) -> str:
3131
return f"Invalid color format: {self.color}"
3232

3333

34+
class ColorInvalidTupleSizeError(TpptException, ValueError):
35+
"""Color tuple size is invalid."""
36+
37+
def __init__(self, color: tuple[Any, ...]) -> None:
38+
self.color = color
39+
40+
@property
41+
def message(self) -> str:
42+
return f"Invalid color tuple size: {self.color}, expected 3(RGB) or 4(RGBA) elements."
43+
44+
3445
class SlideLayoutIndexError(TpptException, IndexError):
3546
"""Slide layout index is out of range."""
3647

@@ -87,3 +98,17 @@ def __init__(
8798
@property
8899
def message(self) -> str:
89100
return f"Invalid setter type. Expected type: {self.expected_type}, but got: {self.actual_type}."
101+
102+
103+
class InvalidColorValueError(TpptException, ValueError):
104+
"""Invalid color value."""
105+
106+
def __init__(
107+
self, type: Literal["red", "green", "blue", "alpha"], value: int
108+
) -> None:
109+
self.type = type
110+
self.value = value
111+
112+
@property
113+
def message(self) -> str:
114+
return f"Invalid {self.type} value: {self.value}. It must be between 0 and 255."

src/tppt/pptx/converter.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pptx.util import Pt as PptxPt
2020

2121
from tppt.types._angle import Angle, Degrees, LiteralAngle
22-
from tppt.types._color import LiteralColor, RGBColor, to_rgb_color
22+
from tppt.types._color import Color, LiteralColor, to_rgb_color
2323
from tppt.types._length import (
2424
CentiMeters,
2525
EnglishMetricUnits,
@@ -90,32 +90,46 @@ def to_tppt_length(length: PptxLength | None) -> Length | None:
9090

9191

9292
@overload
93-
def to_pptx_rgb_color(color: RGBColor | LiteralColor) -> PptxRGBColor: ...
93+
def to_pptx_rgb_color(
94+
color: Color | LiteralColor,
95+
) -> tuple[PptxRGBColor, int | None]: ...
9496

9597

9698
@overload
97-
def to_pptx_rgb_color(color: RGBColor | LiteralColor | None) -> PptxRGBColor | None: ...
98-
99-
100-
def to_pptx_rgb_color(color: RGBColor | LiteralColor | None) -> PptxRGBColor | None:
99+
def to_pptx_rgb_color(
100+
color: Color | LiteralColor | None,
101+
) -> tuple[PptxRGBColor, int | None] | None: ...
102+
103+
104+
def to_pptx_rgb_color(
105+
color: Color | LiteralColor | None,
106+
) -> (
107+
tuple[
108+
PptxRGBColor,
109+
int | None,
110+
]
111+
| None
112+
):
101113
if color is None:
102114
return None
103115

104116
color = to_rgb_color(color)
105117

106-
return PptxRGBColor(color.r, color.g, color.b)
118+
return PptxRGBColor(color.r, color.g, color.b), color.a
107119

108120

109121
@overload
110-
def to_tppt_rgb_color(color: PptxRGBColor) -> RGBColor: ...
122+
def to_tppt_rgb_color(color: PptxRGBColor, alpha: int | None) -> Color: ...
111123

112124

113125
@overload
114-
def to_tppt_rgb_color(color: PptxRGBColor | None) -> RGBColor | None: ...
126+
def to_tppt_rgb_color(
127+
color: PptxRGBColor | None, alpha: int | None
128+
) -> Color | None: ...
115129

116130

117-
def to_tppt_rgb_color(color: PptxRGBColor | None) -> RGBColor | None:
118-
return RGBColor(*color) if color else None
131+
def to_tppt_rgb_color(color: PptxRGBColor | None, alpha: int | None) -> Color | None:
132+
return Color(color[0], color[1], color[2], alpha) if color else None
119133

120134

121135
PptxAngle: TypeAlias = float

src/tppt/pptx/dml/color.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import Self
1+
from typing import Self, cast
22

33
from pptx.dml.color import ColorFormat as PptxColorFormat
4+
from pptx.dml.color import RGBColor as PptxRGBColor
45
from pptx.enum.dml import MSO_THEME_COLOR
56

67
from tppt.pptx.converter import PptxConvertible, to_pptx_rgb_color, to_tppt_rgb_color
7-
from tppt.types import LiteralColor, RGBColor
8+
from tppt.types import Color, LiteralColor
89

910

1011
class ColorFormat(PptxConvertible[PptxColorFormat]):
@@ -25,12 +26,13 @@ def brightness(self, value: float) -> None:
2526
self._pptx.brightness = value
2627

2728
@property
28-
def rgb(self) -> RGBColor | None:
29-
return to_tppt_rgb_color(self._pptx.rgb)
29+
def rgb(self) -> Color:
30+
return to_tppt_rgb_color(cast(PptxRGBColor, self._pptx.rgb), alpha=None)
3031

3132
@rgb.setter
32-
def rgb(self, color: RGBColor | LiteralColor):
33-
self._pptx.rgb = to_pptx_rgb_color(color)
33+
def rgb(self, color: Color | LiteralColor):
34+
pptx_color, _ = to_pptx_rgb_color(color)
35+
self._pptx.rgb = pptx_color
3436

3537
@property
3638
def theme_color(self) -> MSO_THEME_COLOR:

src/tppt/pptx/shape/text.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from tppt.pptx.converter import to_pptx_length, to_pptx_rgb_color
77
from tppt.pptx.text.text_frame import TextFrame
8-
from tppt.types._color import LiteralColor, RGBColor
8+
from tppt.types._color import Color, LiteralColor
99
from tppt.types._length import Length, LiteralLength
1010

1111
from . import RangeProps, Shape
@@ -17,7 +17,7 @@ class TextProps(RangeProps):
1717
size: NotRequired[Length | LiteralLength]
1818
bold: NotRequired[bool]
1919
italic: NotRequired[bool]
20-
color: NotRequired[RGBColor | LiteralColor]
20+
color: NotRequired[Color | LiteralColor]
2121
margin_bottom: NotRequired[Length | LiteralLength]
2222
margin_left: NotRequired[Length | LiteralLength]
2323
vertical_anchor: NotRequired[MSO_ANCHOR]

src/tppt/types/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
from tppt.pptx.shape import RangeProps as _RangeProps
77

8+
from ._color import Color as Color
89
from ._color import LiteralColor as LiteralColor
9-
from ._color import RGBColor as RGBColor
1010
from ._color import to_rgb_color as to_rgb_color
1111
from ._length import (
1212
CentiMeters as CentiMeters,

src/tppt/types/_color.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,87 @@
11
"""Color types for tppt."""
22

3-
from typing import NamedTuple, TypeAlias, assert_never, overload
3+
from dataclasses import dataclass
4+
from typing import TypeAlias, assert_never, overload
45

5-
from tppt.exception import ColorInvalidFormatError
6+
from pptx.dml.color import RGBColor as _PptxRGBColor
7+
from typing_extensions import Annotated, Doc
68

7-
LiteralColor: TypeAlias = tuple[int, int, int] | str
9+
from tppt.exception import (
10+
ColorInvalidFormatError,
11+
ColorInvalidTupleSizeError,
12+
InvalidColorValueError,
13+
)
814

15+
LiteralRGBColor: TypeAlias = tuple[int, int, int]
16+
LiteralRGBAColor: TypeAlias = tuple[int, int, int, int]
17+
LiteralColor: TypeAlias = LiteralRGBColor | LiteralRGBAColor | str
918

10-
class RGBColor(NamedTuple):
11-
r: int
12-
g: int
13-
b: int
19+
20+
@dataclass
21+
class Color:
22+
r: Annotated[int, Doc("red color value")]
23+
g: Annotated[int, Doc("green color value")]
24+
b: Annotated[int, Doc("blue color value")]
25+
a: Annotated[int | None, Doc("alpha color value")] = None
26+
27+
def __post_init__(self):
28+
if not 0 <= self.r <= 255:
29+
raise InvalidColorValueError("red", self.r)
30+
if not 0 <= self.g <= 255:
31+
raise InvalidColorValueError("green", self.g)
32+
if not 0 <= self.b <= 255:
33+
raise InvalidColorValueError("blue", self.b)
34+
if self.a is not None and not 0 <= self.a <= 255:
35+
raise InvalidColorValueError("alpha", self.a)
1436

1537

1638
@overload
17-
def to_rgb_color(color: RGBColor | LiteralColor) -> RGBColor: ...
39+
def to_rgb_color(color: Color | LiteralColor | _PptxRGBColor) -> Color: ...
1840

1941

2042
@overload
21-
def to_rgb_color(color: RGBColor | LiteralColor | None) -> RGBColor | None: ...
43+
def to_rgb_color(
44+
color: Color | LiteralColor | _PptxRGBColor | None,
45+
) -> Color | None: ...
2246

2347

24-
def to_rgb_color(color: RGBColor | LiteralColor | None) -> RGBColor | None:
48+
def to_rgb_color(color: Color | LiteralColor | _PptxRGBColor | None) -> Color | None:
2549
match color:
2650
case None:
2751
return None
28-
case tuple():
29-
return RGBColor(*color)
3052
case str():
3153
if not color.startswith("#"):
3254
raise ColorInvalidFormatError(color)
3355

3456
match len(color):
35-
case 4:
57+
case 4 | 5:
3658
# Note that #123 is the same as #112233
3759
r = int(color[1:2] * 2, 16)
3860
g = int(color[2:3] * 2, 16)
3961
b = int(color[3:4] * 2, 16)
40-
return RGBColor(r, g, b)
62+
a = int(color[4:5] * 2, 16) if len(color) == 5 else None
63+
return Color(r, g, b, a)
4164

42-
case 7:
65+
case 7 | 9:
4366
r = int(color[1:3], 16)
4467
g = int(color[3:5], 16)
4568
b = int(color[5:7], 16)
46-
return RGBColor(r, g, b)
69+
a = int(color[7:9], 16) if len(color) == 9 else None
70+
return Color(r, g, b, a)
4771

4872
case _:
4973
raise ColorInvalidFormatError(color)
74+
case tuple():
75+
match color:
76+
case tuple() if len(color) == 3:
77+
r, g, b = color
78+
return Color(r, g, b)
79+
case tuple() if len(color) == 4:
80+
r, g, b, a = color
81+
return Color(r, g, b, a)
82+
case _:
83+
raise ColorInvalidTupleSizeError(color)
84+
case Color():
85+
return color
5086
case _:
5187
assert_never(color)

tests/test_types_color.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import unittest
44

55
from tppt.exception import ColorInvalidFormatError
6-
from tppt.types._color import RGBColor, to_rgb_color
6+
from tppt.types._color import Color, to_rgb_color
77

88

99
class TestColor(unittest.TestCase):
@@ -12,26 +12,23 @@ class TestColor(unittest.TestCase):
1212
def test_init_with_rgb_hex_short(self):
1313
"""Test initialization with short hex color code (#RGB)."""
1414
color = to_rgb_color("#123")
15-
r, g, b = color
16-
assert r == 0x11
17-
assert g == 0x22
18-
assert b == 0x33
15+
assert color.r == 0x11
16+
assert color.g == 0x22
17+
assert color.b == 0x33
1918

2019
def test_init_with_rgb_hex_long(self):
2120
"""Test initialization with long hex color code (#RRGGBB)."""
2221
color = to_rgb_color("#123456")
23-
r, g, b = color
24-
assert r == 0x12
25-
assert g == 0x34
26-
assert b == 0x56
22+
assert color.r == 0x12
23+
assert color.g == 0x34
24+
assert color.b == 0x56
2725

2826
def test_init_with_rgb_tuple(self):
2927
"""Test initialization with RGB tuple."""
30-
color = RGBColor(10, 20, 30)
31-
r, g, b = color
32-
assert r == 10
33-
assert g == 20
34-
assert b == 30
28+
color = Color(10, 20, 30)
29+
assert color.r == 10
30+
assert color.g == 20
31+
assert color.b == 30
3532

3633
def test_invalid_format_no_hash(self):
3734
"""Test initialization with invalid format (no # prefix)."""
@@ -49,27 +46,24 @@ class TestToColor(unittest.TestCase):
4946

5047
def test_to_color_with_tuple(self):
5148
"""Test to_color with a RGB tuple."""
52-
result = to_rgb_color((10, 20, 30))
53-
assert isinstance(result, RGBColor)
54-
r, g, b = result
55-
assert r == 10
56-
assert g == 20
57-
assert b == 30
49+
color = to_rgb_color((10, 20, 30))
50+
assert isinstance(color, Color)
51+
assert color.r == 10
52+
assert color.g == 20
53+
assert color.b == 30
5854

5955
def test_to_color_with_str(self):
6056
"""Test to_color with a hex string."""
61-
result = to_rgb_color("#123456")
62-
assert isinstance(result, RGBColor)
63-
r, g, b = result
64-
assert r == 0x12
65-
assert g == 0x34
66-
assert b == 0x56
57+
color = to_rgb_color("#123456")
58+
assert isinstance(color, Color)
59+
assert color.r == 0x12
60+
assert color.g == 0x34
61+
assert color.b == 0x56
6762

6863
def test_to_color_with_short_hex(self):
6964
"""Test to_color with a short hex string."""
70-
result = to_rgb_color("#123")
71-
assert isinstance(result, RGBColor)
72-
r, g, b = result
73-
assert r == 0x11
74-
assert g == 0x22
75-
assert b == 0x33
65+
color = to_rgb_color("#123")
66+
assert isinstance(color, Color)
67+
assert color.r == 0x11
68+
assert color.g == 0x22
69+
assert color.b == 0x33

0 commit comments

Comments
 (0)