pkgprint 0.1.0__tar.gz

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.
pkgprint-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rishabh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.2
2
+ Name: pkgprint
3
+ Version: 0.1.0
4
+ Summary: Print and packaging math utilities for developers
5
+ Author: Rishabh
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Rishabh55122/pkgprint
8
+ Project-URL: Bug Tracker, https://github.com/Rishabh55122/pkgprint/issues
9
+ Keywords: print,packaging,design,cmyk,dpi,bleed,typography
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Topic :: Multimedia :: Graphics
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Intended Audience :: Developers
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: build; extra == "dev"
27
+ Requires-Dist: twine; extra == "dev"
28
+
29
+ # pkgprint
30
+
31
+ **Print and packaging math utilities for Python developers.**
32
+
33
+ [![PyPI version](https://badge.fury.io/py/pkgprint.svg)](https://pypi.org/project/pkgprint/)
34
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+
37
+ `pkgprint` is a zero-dependency Python library that handles the fiddly maths behind print and packaging production: unit conversions, colour model translations, standard paper and box sizes, and bleed/margin arithmetic.
38
+
39
+ ---
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install pkgprint
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Quick Start
50
+
51
+ ```python
52
+ import pkgprint
53
+
54
+ # Get A4 dimensions and convert to inches
55
+ w, h = pkgprint.paper_size("A4")
56
+ print(pkgprint.mm_to_inches(w), pkgprint.mm_to_inches(h))
57
+ # → 8.267716535433071 11.692913385826772
58
+
59
+ # Add 3 mm bleed to a business card
60
+ bleed_w, bleed_h = pkgprint.add_bleed(89, 51, bleed_mm=3)
61
+ print(bleed_w, bleed_h)
62
+ # → 95 57
63
+
64
+ # Convert a brand colour from CMYK to RGB
65
+ rgb = pkgprint.cmyk_to_rgb(0, 100, 100, 0) # Pantone-ish red
66
+ print(rgb)
67
+ # → (255, 0, 0)
68
+
69
+ # Convert back from hex to RGB
70
+ print(pkgprint.hex_to_rgb("#FF5733"))
71
+ # → (255, 87, 51)
72
+ ```
73
+
74
+ ---
75
+
76
+ ## API Reference
77
+
78
+ ### `units` — Measurement Conversions
79
+
80
+ | Function | Description |
81
+ |---|---|
82
+ | `mm_to_inches(mm)` | Millimetres → inches |
83
+ | `inches_to_mm(inches)` | Inches → millimetres |
84
+ | `mm_to_points(mm)` | Millimetres → typographic points (1 pt = 1/72 in) |
85
+ | `points_to_mm(points)` | Points → millimetres |
86
+ | `dpi_to_ppi(dpi)` | DPI → PPI (semantic alias, same unit) |
87
+ | `ppi_to_dpi(ppi)` | PPI → DPI (semantic alias, same unit) |
88
+
89
+ ```python
90
+ import pkgprint
91
+
92
+ pkgprint.mm_to_inches(25.4) # → 1.0
93
+ pkgprint.inches_to_mm(8.5) # → 215.9 (US Letter width)
94
+ pkgprint.mm_to_points(210) # → 595.28 (A4 width in points)
95
+ pkgprint.points_to_mm(72) # → 25.4
96
+ pkgprint.dpi_to_ppi(300) # → 300
97
+ pkgprint.ppi_to_dpi(96) # → 96
98
+ ```
99
+
100
+ ---
101
+
102
+ ### `color` — Colour Model Conversions
103
+
104
+ | Function | Description |
105
+ |---|---|
106
+ | `cmyk_to_rgb(c, m, y, k)` | CMYK → RGB. Accepts 0–100 **or** 0–1 inputs. |
107
+ | `rgb_to_cmyk(r, g, b)` | RGB → approximate CMYK (device-independent). |
108
+ | `hex_to_rgb(hex_code)` | CSS hex string → RGB tuple. Supports `#fff` shorthand. |
109
+ | `rgb_to_hex(r, g, b)` | RGB tuple → uppercase CSS hex string. |
110
+
111
+ ```python
112
+ import pkgprint
113
+
114
+ pkgprint.cmyk_to_rgb(0, 100, 100, 0) # → (255, 0, 0) red
115
+ pkgprint.cmyk_to_rgb(0, 1, 1, 0) # → (255, 0, 0) same, 0–1 range
116
+ pkgprint.rgb_to_cmyk(0, 0, 255) # → (100.0, 100.0, 0.0, 0.0) blue
117
+ pkgprint.hex_to_rgb("#FF5733") # → (255, 87, 51)
118
+ pkgprint.hex_to_rgb("#fff") # → (255, 255, 255) shorthand ok
119
+ pkgprint.rgb_to_hex(255, 87, 51) # → '#FF5733'
120
+ ```
121
+
122
+ ---
123
+
124
+ ### `paper` — Standard Sizes
125
+
126
+ | Function | Description |
127
+ |---|---|
128
+ | `paper_size(name)` | `(width, height)` in mm for a named paper size. Case-insensitive. |
129
+ | `list_paper_sizes()` | Sorted list of all supported paper size names. |
130
+ | `box_size(style)` | `(length, width, height)` in mm for a named packaging box. Case-insensitive. |
131
+
132
+ **Supported paper sizes:** ISO A0–A8, B0–B6, C4–C6 (envelopes), Letter, Legal, Tabloid, Executive, ANSI A–E, Business Card, Postcard, and more.
133
+
134
+ **Supported box styles:** RSC Small/Medium/Large, Mailer A4/A5, Gift Small/Medium/Large, Tuck Small/Medium/Large.
135
+
136
+ ```python
137
+ import pkgprint
138
+
139
+ pkgprint.paper_size("A4") # → (210, 297)
140
+ pkgprint.paper_size("letter") # → (216, 279) case-insensitive
141
+ pkgprint.box_size("RSC Medium") # → (305, 229, 152)
142
+ pkgprint.list_paper_sizes() # → ['A0', 'A1', ..., 'Tabloid', ...]
143
+ ```
144
+
145
+ ---
146
+
147
+ ### `print_specs` — Print Production Math
148
+
149
+ | Function | Description |
150
+ |---|---|
151
+ | `add_bleed(width, height, bleed_mm=3)` | Trim size → artwork size with bleed on all four sides. |
152
+ | `safe_margin(width, height, margin_mm=5)` | Trim size → safe content area (removes margins). |
153
+ | `trim_size(width, height, bleed_mm=3)` | Artwork size (with bleed) → trim size. |
154
+
155
+ ```python
156
+ import pkgprint
157
+
158
+ # Business card: 89 × 51 mm → with 3 mm bleed → 95 × 57 mm
159
+ pkgprint.add_bleed(89, 51) # → (95, 57)
160
+
161
+ # A4 safe content area with 5 mm margins
162
+ pkgprint.safe_margin(210, 297, margin_mm=5) # → (200, 287)
163
+
164
+ # Reverse: what is the trim size of a 220 × 307 mm file with 5 mm bleed?
165
+ pkgprint.trim_size(220, 307, bleed_mm=5) # → (210, 297)
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Build & Publish
171
+
172
+ ```bash
173
+ # Install build tools
174
+ pip install build twine
175
+
176
+ # Build source + wheel
177
+ python -m build
178
+
179
+ # Test publish (TestPyPI)
180
+ twine upload --repository testpypi dist/*
181
+
182
+ # Real publish
183
+ twine upload dist/*
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Running Tests
189
+
190
+ ```bash
191
+ pip install pytest
192
+ pytest
193
+ ```
194
+
195
+ ---
196
+
197
+ ## License
198
+
199
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,171 @@
1
+ # pkgprint
2
+
3
+ **Print and packaging math utilities for Python developers.**
4
+
5
+ [![PyPI version](https://badge.fury.io/py/pkgprint.svg)](https://pypi.org/project/pkgprint/)
6
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+
9
+ `pkgprint` is a zero-dependency Python library that handles the fiddly maths behind print and packaging production: unit conversions, colour model translations, standard paper and box sizes, and bleed/margin arithmetic.
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install pkgprint
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ import pkgprint
25
+
26
+ # Get A4 dimensions and convert to inches
27
+ w, h = pkgprint.paper_size("A4")
28
+ print(pkgprint.mm_to_inches(w), pkgprint.mm_to_inches(h))
29
+ # → 8.267716535433071 11.692913385826772
30
+
31
+ # Add 3 mm bleed to a business card
32
+ bleed_w, bleed_h = pkgprint.add_bleed(89, 51, bleed_mm=3)
33
+ print(bleed_w, bleed_h)
34
+ # → 95 57
35
+
36
+ # Convert a brand colour from CMYK to RGB
37
+ rgb = pkgprint.cmyk_to_rgb(0, 100, 100, 0) # Pantone-ish red
38
+ print(rgb)
39
+ # → (255, 0, 0)
40
+
41
+ # Convert back from hex to RGB
42
+ print(pkgprint.hex_to_rgb("#FF5733"))
43
+ # → (255, 87, 51)
44
+ ```
45
+
46
+ ---
47
+
48
+ ## API Reference
49
+
50
+ ### `units` — Measurement Conversions
51
+
52
+ | Function | Description |
53
+ |---|---|
54
+ | `mm_to_inches(mm)` | Millimetres → inches |
55
+ | `inches_to_mm(inches)` | Inches → millimetres |
56
+ | `mm_to_points(mm)` | Millimetres → typographic points (1 pt = 1/72 in) |
57
+ | `points_to_mm(points)` | Points → millimetres |
58
+ | `dpi_to_ppi(dpi)` | DPI → PPI (semantic alias, same unit) |
59
+ | `ppi_to_dpi(ppi)` | PPI → DPI (semantic alias, same unit) |
60
+
61
+ ```python
62
+ import pkgprint
63
+
64
+ pkgprint.mm_to_inches(25.4) # → 1.0
65
+ pkgprint.inches_to_mm(8.5) # → 215.9 (US Letter width)
66
+ pkgprint.mm_to_points(210) # → 595.28 (A4 width in points)
67
+ pkgprint.points_to_mm(72) # → 25.4
68
+ pkgprint.dpi_to_ppi(300) # → 300
69
+ pkgprint.ppi_to_dpi(96) # → 96
70
+ ```
71
+
72
+ ---
73
+
74
+ ### `color` — Colour Model Conversions
75
+
76
+ | Function | Description |
77
+ |---|---|
78
+ | `cmyk_to_rgb(c, m, y, k)` | CMYK → RGB. Accepts 0–100 **or** 0–1 inputs. |
79
+ | `rgb_to_cmyk(r, g, b)` | RGB → approximate CMYK (device-independent). |
80
+ | `hex_to_rgb(hex_code)` | CSS hex string → RGB tuple. Supports `#fff` shorthand. |
81
+ | `rgb_to_hex(r, g, b)` | RGB tuple → uppercase CSS hex string. |
82
+
83
+ ```python
84
+ import pkgprint
85
+
86
+ pkgprint.cmyk_to_rgb(0, 100, 100, 0) # → (255, 0, 0) red
87
+ pkgprint.cmyk_to_rgb(0, 1, 1, 0) # → (255, 0, 0) same, 0–1 range
88
+ pkgprint.rgb_to_cmyk(0, 0, 255) # → (100.0, 100.0, 0.0, 0.0) blue
89
+ pkgprint.hex_to_rgb("#FF5733") # → (255, 87, 51)
90
+ pkgprint.hex_to_rgb("#fff") # → (255, 255, 255) shorthand ok
91
+ pkgprint.rgb_to_hex(255, 87, 51) # → '#FF5733'
92
+ ```
93
+
94
+ ---
95
+
96
+ ### `paper` — Standard Sizes
97
+
98
+ | Function | Description |
99
+ |---|---|
100
+ | `paper_size(name)` | `(width, height)` in mm for a named paper size. Case-insensitive. |
101
+ | `list_paper_sizes()` | Sorted list of all supported paper size names. |
102
+ | `box_size(style)` | `(length, width, height)` in mm for a named packaging box. Case-insensitive. |
103
+
104
+ **Supported paper sizes:** ISO A0–A8, B0–B6, C4–C6 (envelopes), Letter, Legal, Tabloid, Executive, ANSI A–E, Business Card, Postcard, and more.
105
+
106
+ **Supported box styles:** RSC Small/Medium/Large, Mailer A4/A5, Gift Small/Medium/Large, Tuck Small/Medium/Large.
107
+
108
+ ```python
109
+ import pkgprint
110
+
111
+ pkgprint.paper_size("A4") # → (210, 297)
112
+ pkgprint.paper_size("letter") # → (216, 279) case-insensitive
113
+ pkgprint.box_size("RSC Medium") # → (305, 229, 152)
114
+ pkgprint.list_paper_sizes() # → ['A0', 'A1', ..., 'Tabloid', ...]
115
+ ```
116
+
117
+ ---
118
+
119
+ ### `print_specs` — Print Production Math
120
+
121
+ | Function | Description |
122
+ |---|---|
123
+ | `add_bleed(width, height, bleed_mm=3)` | Trim size → artwork size with bleed on all four sides. |
124
+ | `safe_margin(width, height, margin_mm=5)` | Trim size → safe content area (removes margins). |
125
+ | `trim_size(width, height, bleed_mm=3)` | Artwork size (with bleed) → trim size. |
126
+
127
+ ```python
128
+ import pkgprint
129
+
130
+ # Business card: 89 × 51 mm → with 3 mm bleed → 95 × 57 mm
131
+ pkgprint.add_bleed(89, 51) # → (95, 57)
132
+
133
+ # A4 safe content area with 5 mm margins
134
+ pkgprint.safe_margin(210, 297, margin_mm=5) # → (200, 287)
135
+
136
+ # Reverse: what is the trim size of a 220 × 307 mm file with 5 mm bleed?
137
+ pkgprint.trim_size(220, 307, bleed_mm=5) # → (210, 297)
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Build & Publish
143
+
144
+ ```bash
145
+ # Install build tools
146
+ pip install build twine
147
+
148
+ # Build source + wheel
149
+ python -m build
150
+
151
+ # Test publish (TestPyPI)
152
+ twine upload --repository testpypi dist/*
153
+
154
+ # Real publish
155
+ twine upload dist/*
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Running Tests
161
+
162
+ ```bash
163
+ pip install pytest
164
+ pytest
165
+ ```
166
+
167
+ ---
168
+
169
+ ## License
170
+
171
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,42 @@
1
+ """
2
+ pkgprint — Print & packaging math utilities for developers.
3
+
4
+ Provides unit conversions, color model conversions, standard paper/box sizes,
5
+ and bleed/margin calculations useful in print, packaging, and design workflows.
6
+
7
+ Example:
8
+ >>> import pkgprint
9
+ >>> pkgprint.mm_to_inches(210)
10
+ 8.267716535433071
11
+ >>> pkgprint.paper_size("A4")
12
+ (210, 297)
13
+ """
14
+
15
+ from .units import mm_to_inches, inches_to_mm, mm_to_points, points_to_mm, dpi_to_ppi, ppi_to_dpi
16
+ from .color import cmyk_to_rgb, rgb_to_cmyk, hex_to_rgb, rgb_to_hex
17
+ from .paper import paper_size, list_paper_sizes, box_size
18
+ from .print_specs import add_bleed, safe_margin, trim_size
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = [
22
+ # units
23
+ "mm_to_inches",
24
+ "inches_to_mm",
25
+ "mm_to_points",
26
+ "points_to_mm",
27
+ "dpi_to_ppi",
28
+ "ppi_to_dpi",
29
+ # color
30
+ "cmyk_to_rgb",
31
+ "rgb_to_cmyk",
32
+ "hex_to_rgb",
33
+ "rgb_to_hex",
34
+ # paper
35
+ "paper_size",
36
+ "list_paper_sizes",
37
+ "box_size",
38
+ # print specs
39
+ "add_bleed",
40
+ "safe_margin",
41
+ "trim_size",
42
+ ]
@@ -0,0 +1,200 @@
1
+ """
2
+ color.py — Color model conversions for print and screen workflows.
3
+
4
+ Conversion references:
5
+ - CMYK → RGB: standard ICC-style formula using key (K) channel.
6
+ - RGB → CMYK: inverse of the above (approximate, device-independent).
7
+ - Hex ↔ RGB: CSS hex notation, 6-digit only (with optional '#' prefix).
8
+
9
+ CMYK inputs are accepted in **both** 0–100 and 0–1 ranges and are
10
+ auto-detected: values > 1 are treated as percentage (0–100).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ def _normalise_cmyk(c: float, m: float, y: float, k: float) -> tuple[float, float, float, float]:
17
+ """Return CMYK values normalised to the 0–1 range."""
18
+ values = (c, m, y, k)
19
+ if any(v > 1 for v in values):
20
+ # Assume 0–100 percentage input
21
+ c, m, y, k = c / 100, m / 100, y / 100, k / 100
22
+ return c, m, y, k
23
+
24
+
25
+ def cmyk_to_rgb(c: float, m: float, y: float, k: float) -> tuple[int, int, int]:
26
+ """Convert CMYK colour values to RGB.
27
+
28
+ Inputs are accepted in either 0–100 (percentage) or 0–1 range.
29
+ If any value exceeds 1, all four are treated as percentages.
30
+
31
+ Formula (ICC standard):
32
+ R = 255 × (1 − C) × (1 − K)
33
+ G = 255 × (1 − M) × (1 − K)
34
+ B = 255 × (1 − Y) × (1 − K)
35
+
36
+ Args:
37
+ c: Cyan component (0–100 or 0–1).
38
+ m: Magenta component (0–100 or 0–1).
39
+ y: Yellow component (0–100 or 0–1).
40
+ k: Key (Black) component (0–100 or 0–1).
41
+
42
+ Returns:
43
+ Tuple of (R, G, B) integers in the range 0–255.
44
+
45
+ Raises:
46
+ ValueError: If any normalised value falls outside 0–1.
47
+
48
+ Example:
49
+ >>> cmyk_to_rgb(0, 100, 100, 0) # pure red in CMYK
50
+ (255, 0, 0)
51
+ >>> cmyk_to_rgb(0, 0, 0, 0) # white
52
+ (255, 255, 255)
53
+ >>> cmyk_to_rgb(0, 0, 0, 100) # black
54
+ (0, 0, 0)
55
+ """
56
+ for name, val in zip(("c", "m", "y", "k"), (c, m, y, k)):
57
+ if not isinstance(val, (int, float)):
58
+ raise TypeError(f"{name!r} must be a number, got {type(val).__name__!r}")
59
+
60
+ c, m, y, k = _normalise_cmyk(c, m, y, k)
61
+
62
+ for name, val in zip(("c", "m", "y", "k"), (c, m, y, k)):
63
+ if not (0.0 <= val <= 1.0):
64
+ raise ValueError(f"{name!r} must be in range 0–1 (or 0–100), got {val}")
65
+
66
+ r = round(255 * (1 - c) * (1 - k))
67
+ g = round(255 * (1 - m) * (1 - k))
68
+ b = round(255 * (1 - y) * (1 - k))
69
+ return (r, g, b)
70
+
71
+
72
+ def rgb_to_cmyk(r: int, g: int, b: int) -> tuple[float, float, float, float]:
73
+ """Convert RGB colour values to approximate CMYK.
74
+
75
+ This is a device-independent approximation (no ICC profile).
76
+ Output values are in the 0–100 percentage range, rounded to 2 dp.
77
+
78
+ Formula:
79
+ K = 1 − max(R, G, B) / 255
80
+ C = (1 − R/255 − K) / (1 − K) [0 when K=1]
81
+ M = (1 − G/255 − K) / (1 − K)
82
+ Y = (1 − B/255 − K) / (1 − K)
83
+
84
+ Args:
85
+ r: Red channel (0–255).
86
+ g: Green channel (0–255).
87
+ b: Blue channel (0–255).
88
+
89
+ Returns:
90
+ Tuple of (C, M, Y, K) floats in the range 0.0–100.0.
91
+
92
+ Raises:
93
+ ValueError: If any channel is outside 0–255.
94
+
95
+ Example:
96
+ >>> rgb_to_cmyk(255, 0, 0) # red
97
+ (0.0, 100.0, 100.0, 0.0)
98
+ >>> rgb_to_cmyk(0, 0, 0) # black
99
+ (0.0, 0.0, 0.0, 100.0)
100
+ >>> rgb_to_cmyk(255, 255, 255) # white
101
+ (0.0, 0.0, 0.0, 0.0)
102
+ """
103
+ for name, val in zip(("r", "g", "b"), (r, g, b)):
104
+ if not isinstance(val, (int, float)):
105
+ raise TypeError(f"{name!r} must be a number, got {type(val).__name__!r}")
106
+ if not (0 <= val <= 255):
107
+ raise ValueError(f"{name!r} must be in range 0–255, got {val}")
108
+
109
+ r_, g_, b_ = r / 255, g / 255, b / 255
110
+ k = 1 - max(r_, g_, b_)
111
+
112
+ if k == 1.0:
113
+ # Pure black — avoid division by zero
114
+ return (0.0, 0.0, 0.0, 100.0)
115
+
116
+ c = (1 - r_ - k) / (1 - k)
117
+ m = (1 - g_ - k) / (1 - k)
118
+ y = (1 - b_ - k) / (1 - k)
119
+
120
+ return (
121
+ round(c * 100, 2),
122
+ round(m * 100, 2),
123
+ round(y * 100, 2),
124
+ round(k * 100, 2),
125
+ )
126
+
127
+
128
+ def hex_to_rgb(hex_code: str) -> tuple[int, int, int]:
129
+ """Convert a CSS hex colour string to an RGB tuple.
130
+
131
+ Accepts 6-digit hex strings with or without a leading '#'.
132
+ 3-digit shorthand (e.g. ``#fff``) is also supported and expanded.
133
+
134
+ Args:
135
+ hex_code: Hex colour string, e.g. ``"#FF5733"`` or ``"ff5733"``.
136
+
137
+ Returns:
138
+ Tuple of (R, G, B) integers in the range 0–255.
139
+
140
+ Raises:
141
+ ValueError: If *hex_code* is not a valid 3- or 6-digit hex colour.
142
+
143
+ Example:
144
+ >>> hex_to_rgb("#FF5733")
145
+ (255, 87, 51)
146
+ >>> hex_to_rgb("ffffff")
147
+ (255, 255, 255)
148
+ >>> hex_to_rgb("#000")
149
+ (0, 0, 0)
150
+ """
151
+ if not isinstance(hex_code, str):
152
+ raise TypeError(f"hex_code must be a string, got {type(hex_code).__name__!r}")
153
+
154
+ code = hex_code.strip().lstrip("#")
155
+
156
+ if len(code) == 3:
157
+ code = "".join(ch * 2 for ch in code)
158
+
159
+ if len(code) != 6:
160
+ raise ValueError(
161
+ f"Invalid hex colour {hex_code!r}: expected 3 or 6 hex digits (with optional '#')."
162
+ )
163
+
164
+ try:
165
+ r = int(code[0:2], 16)
166
+ g = int(code[2:4], 16)
167
+ b = int(code[4:6], 16)
168
+ except ValueError:
169
+ raise ValueError(f"Invalid hex colour {hex_code!r}: contains non-hex characters.")
170
+
171
+ return (r, g, b)
172
+
173
+
174
+ def rgb_to_hex(r: int, g: int, b: int) -> str:
175
+ """Convert RGB integers to a CSS hex colour string (uppercase, with '#').
176
+
177
+ Args:
178
+ r: Red channel (0–255).
179
+ g: Green channel (0–255).
180
+ b: Blue channel (0–255).
181
+
182
+ Returns:
183
+ Uppercase hex string, e.g. ``"#FF5733"``.
184
+
185
+ Raises:
186
+ ValueError: If any channel is outside 0–255.
187
+
188
+ Example:
189
+ >>> rgb_to_hex(255, 87, 51)
190
+ '#FF5733'
191
+ >>> rgb_to_hex(0, 0, 0)
192
+ '#000000'
193
+ """
194
+ for name, val in zip(("r", "g", "b"), (r, g, b)):
195
+ if not isinstance(val, (int, float)):
196
+ raise TypeError(f"{name!r} must be a number, got {type(val).__name__!r}")
197
+ if not (0 <= val <= 255):
198
+ raise ValueError(f"{name!r} must be in range 0–255, got {val}")
199
+
200
+ return "#{:02X}{:02X}{:02X}".format(int(r), int(g), int(b))