pkgprint 0.1.0__tar.gz → 0.2.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 → pkgprint-0.2.0}/PKG-INFO +3 -1
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint/__init__.py +24 -2
- pkgprint-0.2.0/pkgprint/accessibility.py +318 -0
- pkgprint-0.2.0/pkgprint/check.py +379 -0
- pkgprint-0.2.0/pkgprint/dieline.py +266 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint.egg-info/PKG-INFO +3 -1
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint.egg-info/SOURCES.txt +6 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint.egg-info/requires.txt +1 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pyproject.toml +5 -1
- pkgprint-0.2.0/tests/test_accessibility.py +214 -0
- pkgprint-0.2.0/tests/test_check.py +275 -0
- pkgprint-0.2.0/tests/test_dieline.py +216 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/LICENSE +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/README.md +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint/color.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint/paper.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint/print_specs.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint/units.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint.egg-info/dependency_links.txt +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/pkgprint.egg-info/top_level.txt +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/setup.cfg +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/tests/test_color.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/tests/test_paper.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/tests/test_print_specs.py +0 -0
- {pkgprint-0.1.0 → pkgprint-0.2.0}/tests/test_units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: pkgprint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Print and packaging math utilities for developers
|
|
5
5
|
Author: Rishabh
|
|
6
6
|
License: MIT
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
18
|
Classifier: Operating System :: OS Independent
|
|
18
19
|
Classifier: Topic :: Multimedia :: Graphics
|
|
@@ -21,6 +22,7 @@ Classifier: Intended Audience :: Developers
|
|
|
21
22
|
Requires-Python: >=3.8
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: Pillow>=9.0
|
|
24
26
|
Provides-Extra: dev
|
|
25
27
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
28
|
Requires-Dist: build; extra == "dev"
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
pkgprint — Print & packaging math utilities for developers.
|
|
3
3
|
|
|
4
4
|
Provides unit conversions, color model conversions, standard paper/box sizes,
|
|
5
|
-
|
|
5
|
+
bleed/margin calculations, print-readiness file checking, color accessibility
|
|
6
|
+
checking, and dieline panel calculations.
|
|
6
7
|
|
|
7
8
|
Example:
|
|
8
9
|
>>> import pkgprint
|
|
@@ -16,8 +17,11 @@ from .units import mm_to_inches, inches_to_mm, mm_to_points, points_to_mm, dpi_t
|
|
|
16
17
|
from .color import cmyk_to_rgb, rgb_to_cmyk, hex_to_rgb, rgb_to_hex
|
|
17
18
|
from .paper import paper_size, list_paper_sizes, box_size
|
|
18
19
|
from .print_specs import add_bleed, safe_margin, trim_size
|
|
20
|
+
from .check import CheckResult, PrintReport, check_dpi, check_color_mode, check_dimensions, full_report
|
|
21
|
+
from .accessibility import ContrastResult, relative_luminance, contrast_ratio, check_wcag_compliance, suggest_darker_or_lighter
|
|
22
|
+
from .dieline import Panel, rsc_flat_size, mailer_flat_size, panel_layout
|
|
19
23
|
|
|
20
|
-
__version__ = "0.
|
|
24
|
+
__version__ = "0.2.0"
|
|
21
25
|
__all__ = [
|
|
22
26
|
# units
|
|
23
27
|
"mm_to_inches",
|
|
@@ -39,4 +43,22 @@ __all__ = [
|
|
|
39
43
|
"add_bleed",
|
|
40
44
|
"safe_margin",
|
|
41
45
|
"trim_size",
|
|
46
|
+
# check (print-readiness)
|
|
47
|
+
"CheckResult",
|
|
48
|
+
"PrintReport",
|
|
49
|
+
"check_dpi",
|
|
50
|
+
"check_color_mode",
|
|
51
|
+
"check_dimensions",
|
|
52
|
+
"full_report",
|
|
53
|
+
# accessibility (WCAG contrast)
|
|
54
|
+
"ContrastResult",
|
|
55
|
+
"relative_luminance",
|
|
56
|
+
"contrast_ratio",
|
|
57
|
+
"check_wcag_compliance",
|
|
58
|
+
"suggest_darker_or_lighter",
|
|
59
|
+
# dieline (box panel layout)
|
|
60
|
+
"Panel",
|
|
61
|
+
"rsc_flat_size",
|
|
62
|
+
"mailer_flat_size",
|
|
63
|
+
"panel_layout",
|
|
42
64
|
]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
accessibility.py — Colour accessibility and contrast checking.
|
|
3
|
+
|
|
4
|
+
Checks whether foreground/background colour pairs (e.g. text on a packaging
|
|
5
|
+
background) have sufficient contrast for legibility, using the WCAG 2.1
|
|
6
|
+
guidelines.
|
|
7
|
+
|
|
8
|
+
References:
|
|
9
|
+
- WCAG 2.1 relative luminance formula:
|
|
10
|
+
https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
|
11
|
+
- WCAG 2.1 contrast ratio formula:
|
|
12
|
+
https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
|
|
13
|
+
- WCAG 2.1 success criterion 1.4.3 (AA) and 1.4.6 (AAA):
|
|
14
|
+
https://www.w3.org/TR/WCAG21/#contrast-minimum
|
|
15
|
+
|
|
16
|
+
WCAG contrast thresholds:
|
|
17
|
+
┌──────────────────┬────────────┬──────────────┐
|
|
18
|
+
│ Level / Size │ Min ratio │ Typical use │
|
|
19
|
+
├──────────────────┼────────────┼──────────────┤
|
|
20
|
+
│ AA / normal │ 4.5 : 1 │ Body copy │
|
|
21
|
+
│ AA / large │ 3.0 : 1 │ Headings ≥18pt│
|
|
22
|
+
│ AAA / normal │ 7.0 : 1 │ High contrast│
|
|
23
|
+
│ AAA / large │ 4.5 : 1 │ Large + high │
|
|
24
|
+
└──────────────────┴────────────┴──────────────┘
|
|
25
|
+
"Large" text = bold ≥ 14pt or regular ≥ 18pt.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# WCAG thresholds
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
_THRESHOLDS: dict[tuple[str, str], float] = {
|
|
38
|
+
("AA", "normal"): 4.5,
|
|
39
|
+
("AA", "large"): 3.0,
|
|
40
|
+
("AAA", "normal"): 7.0,
|
|
41
|
+
("AAA", "large"): 4.5,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_VALID_LEVELS = ("AA", "AAA")
|
|
45
|
+
_VALID_SIZES = ("normal", "large")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Result dataclass
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ContrastResult:
|
|
54
|
+
"""Result of a WCAG contrast compliance check."""
|
|
55
|
+
|
|
56
|
+
passed: bool
|
|
57
|
+
ratio: float # actual contrast ratio (1–21)
|
|
58
|
+
required_ratio: float # WCAG threshold for the requested level/size
|
|
59
|
+
level: str # "AA" or "AAA"
|
|
60
|
+
text_size: str # "normal" or "large"
|
|
61
|
+
message: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Public functions
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def relative_luminance(r: int, g: int, b: int) -> float:
|
|
69
|
+
"""Calculate the WCAG 2.1 relative luminance of an sRGB colour.
|
|
70
|
+
|
|
71
|
+
Applies the piecewise sRGB gamma correction specified in WCAG 2.1
|
|
72
|
+
(section 1.4.3) and returns a value in the range [0.0, 1.0], where
|
|
73
|
+
0.0 is absolute black and 1.0 is absolute white.
|
|
74
|
+
|
|
75
|
+
Formula:
|
|
76
|
+
For each channel c in (R, G, B), normalised to 0–1:
|
|
77
|
+
c_lin = c / 12.92 if c ≤ 0.04045
|
|
78
|
+
c_lin = ((c + 0.055) / 1.055) ** 2.4 otherwise
|
|
79
|
+
L = 0.2126 × R_lin + 0.7152 × G_lin + 0.0722 × B_lin
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
r: Red channel (0–255).
|
|
83
|
+
g: Green channel (0–255).
|
|
84
|
+
b: Blue channel (0–255).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Relative luminance as a float in [0.0, 1.0].
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
TypeError: If any channel is not a numeric type.
|
|
91
|
+
ValueError: If any channel is outside 0–255.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> round(relative_luminance(255, 255, 255), 4)
|
|
95
|
+
1.0
|
|
96
|
+
>>> round(relative_luminance(0, 0, 0), 4)
|
|
97
|
+
0.0
|
|
98
|
+
>>> round(relative_luminance(255, 0, 0), 4)
|
|
99
|
+
0.2126
|
|
100
|
+
"""
|
|
101
|
+
for name, val in zip(("r", "g", "b"), (r, g, b)):
|
|
102
|
+
if not isinstance(val, (int, float)):
|
|
103
|
+
raise TypeError(f"{name!r} must be a number, got {type(val).__name__!r}")
|
|
104
|
+
if not (0 <= val <= 255):
|
|
105
|
+
raise ValueError(f"{name!r} must be in range 0–255, got {val}")
|
|
106
|
+
|
|
107
|
+
def _linearise(c: float) -> float:
|
|
108
|
+
c_norm = c / 255.0
|
|
109
|
+
if c_norm <= 0.04045:
|
|
110
|
+
return c_norm / 12.92
|
|
111
|
+
return ((c_norm + 0.055) / 1.055) ** 2.4
|
|
112
|
+
|
|
113
|
+
r_lin = _linearise(r)
|
|
114
|
+
g_lin = _linearise(g)
|
|
115
|
+
b_lin = _linearise(b)
|
|
116
|
+
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def contrast_ratio(
|
|
120
|
+
rgb1: tuple[int, int, int],
|
|
121
|
+
rgb2: tuple[int, int, int],
|
|
122
|
+
) -> float:
|
|
123
|
+
"""Calculate the WCAG 2.1 contrast ratio between two sRGB colours.
|
|
124
|
+
|
|
125
|
+
The ratio is always returned as (L_lighter + 0.05) / (L_darker + 0.05)
|
|
126
|
+
regardless of the order of the arguments, so the result is always ≥ 1.0.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
rgb1: First colour as an (R, G, B) tuple with values 0–255.
|
|
130
|
+
rgb2: Second colour as an (R, G, B) tuple with values 0–255.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Contrast ratio as a float in [1.0, 21.0], rounded to 2 decimal places.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
TypeError: If either argument is not a tuple/list of three numbers.
|
|
137
|
+
ValueError: If any channel value is outside 0–255.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
>>> contrast_ratio((255, 255, 255), (0, 0, 0))
|
|
141
|
+
21.0
|
|
142
|
+
>>> contrast_ratio((0, 0, 0), (0, 0, 0))
|
|
143
|
+
1.0
|
|
144
|
+
>>> contrast_ratio((255, 255, 255), (255, 255, 255))
|
|
145
|
+
1.0
|
|
146
|
+
"""
|
|
147
|
+
for name, rgb in (("rgb1", rgb1), ("rgb2", rgb2)):
|
|
148
|
+
if not isinstance(rgb, (tuple, list)) or len(rgb) != 3:
|
|
149
|
+
raise TypeError(
|
|
150
|
+
f"{name!r} must be a tuple of three integers (R, G, B), "
|
|
151
|
+
f"got {type(rgb).__name__!r}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
l1 = relative_luminance(*rgb1)
|
|
155
|
+
l2 = relative_luminance(*rgb2)
|
|
156
|
+
lighter = max(l1, l2)
|
|
157
|
+
darker = min(l1, l2)
|
|
158
|
+
ratio = (lighter + 0.05) / (darker + 0.05)
|
|
159
|
+
return round(ratio, 2)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_wcag_compliance(
|
|
163
|
+
rgb1: tuple[int, int, int],
|
|
164
|
+
rgb2: tuple[int, int, int],
|
|
165
|
+
level: str = "AA",
|
|
166
|
+
text_size: str = "normal",
|
|
167
|
+
) -> ContrastResult:
|
|
168
|
+
"""Check whether two colours meet a WCAG contrast requirement.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
rgb1: First colour (typically foreground/text) as (R, G, B).
|
|
172
|
+
rgb2: Second colour (typically background) as (R, G, B).
|
|
173
|
+
level: WCAG conformance level: ``"AA"`` (default) or ``"AAA"``.
|
|
174
|
+
text_size: ``"normal"`` (default, body copy) or ``"large"``
|
|
175
|
+
(bold ≥ 14pt or regular ≥ 18pt).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
:class:`ContrastResult` with ``.passed``, ``.ratio``,
|
|
179
|
+
``.required_ratio``, ``.level``, ``.text_size``, and ``.message``.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
TypeError: If any colour argument is not a 3-tuple of numbers.
|
|
183
|
+
ValueError: If any channel is outside 0–255, *level* is not
|
|
184
|
+
``"AA"``/``"AAA"``, or *text_size* is not
|
|
185
|
+
``"normal"``/``"large"``.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
>>> result = check_wcag_compliance((0, 0, 0), (255, 255, 255))
|
|
189
|
+
>>> result.passed
|
|
190
|
+
True
|
|
191
|
+
>>> result.ratio
|
|
192
|
+
21.0
|
|
193
|
+
>>> check_wcag_compliance((120, 120, 120), (255, 255, 255)).passed
|
|
194
|
+
False
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(level, str) or level.upper() not in _VALID_LEVELS:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"level must be one of {_VALID_LEVELS}, got {level!r}"
|
|
199
|
+
)
|
|
200
|
+
if not isinstance(text_size, str) or text_size.lower() not in _VALID_SIZES:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"text_size must be one of {_VALID_SIZES}, got {text_size!r}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
level = level.upper()
|
|
206
|
+
text_size = text_size.lower()
|
|
207
|
+
required = _THRESHOLDS[(level, text_size)]
|
|
208
|
+
ratio = contrast_ratio(rgb1, rgb2)
|
|
209
|
+
passed = ratio >= required
|
|
210
|
+
|
|
211
|
+
if passed:
|
|
212
|
+
msg = (
|
|
213
|
+
f"Contrast ratio {ratio:.2f}:1 meets WCAG {level} ({text_size} text) "
|
|
214
|
+
f"requirement of {required}:1."
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
msg = (
|
|
218
|
+
f"Contrast ratio {ratio:.2f}:1 does NOT meet WCAG {level} ({text_size} text) "
|
|
219
|
+
f"requirement of {required}:1. Difference: {required - ratio:.2f}."
|
|
220
|
+
)
|
|
221
|
+
return ContrastResult(
|
|
222
|
+
passed=passed,
|
|
223
|
+
ratio=ratio,
|
|
224
|
+
required_ratio=required,
|
|
225
|
+
level=level,
|
|
226
|
+
text_size=text_size,
|
|
227
|
+
message=msg,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def suggest_darker_or_lighter(
|
|
232
|
+
rgb_text: tuple[int, int, int],
|
|
233
|
+
rgb_background: tuple[int, int, int],
|
|
234
|
+
target_ratio: float = 4.5,
|
|
235
|
+
) -> tuple[int, int, int]:
|
|
236
|
+
"""Suggest an adjusted text colour that meets the target contrast ratio.
|
|
237
|
+
|
|
238
|
+
The hue is preserved: the function only adjusts brightness by scaling the
|
|
239
|
+
RGB channels toward black (if the text is lighter than the background) or
|
|
240
|
+
toward white (if the text is darker than the background). If the current
|
|
241
|
+
contrast already meets *target_ratio*, the original colour is returned
|
|
242
|
+
unchanged.
|
|
243
|
+
|
|
244
|
+
This is a practical approximation — it does not use HSL or perceptual
|
|
245
|
+
colour spaces, so results look natural for most colours but may be
|
|
246
|
+
unexpected for highly saturated hues near the extreme ends of the scale.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
rgb_text: Current text colour as (R, G, B).
|
|
250
|
+
rgb_background: Background colour as (R, G, B).
|
|
251
|
+
target_ratio: Desired WCAG contrast ratio. Default is 4.5 (AA normal).
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Adjusted (R, G, B) tuple that meets or exceeds *target_ratio*,
|
|
255
|
+
clamped to 0–255. Returns ``(0, 0, 0)`` or ``(255, 255, 255)`` if
|
|
256
|
+
no adjustment within valid RGB range can meet the target.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
TypeError: If any colour argument is not a 3-tuple of numbers, or
|
|
260
|
+
*target_ratio* is not numeric.
|
|
261
|
+
ValueError: If any channel is outside 0–255, or *target_ratio*
|
|
262
|
+
is outside 1–21.
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
>>> adjusted = suggest_darker_or_lighter((120, 120, 120), (255, 255, 255))
|
|
266
|
+
>>> r = check_wcag_compliance(adjusted, (255, 255, 255))
|
|
267
|
+
>>> r.passed
|
|
268
|
+
True
|
|
269
|
+
"""
|
|
270
|
+
for name, rgb in (("rgb_text", rgb_text), ("rgb_background", rgb_background)):
|
|
271
|
+
if not isinstance(rgb, (tuple, list)) or len(rgb) != 3:
|
|
272
|
+
raise TypeError(
|
|
273
|
+
f"{name!r} must be a tuple of three integers (R, G, B), "
|
|
274
|
+
f"got {type(rgb).__name__!r}"
|
|
275
|
+
)
|
|
276
|
+
if not isinstance(target_ratio, (int, float)):
|
|
277
|
+
raise TypeError(
|
|
278
|
+
f"target_ratio must be a number, got {type(target_ratio).__name__!r}"
|
|
279
|
+
)
|
|
280
|
+
if not (1.0 <= target_ratio <= 21.0):
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"target_ratio must be between 1 and 21, got {target_ratio}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Already passes? Return as-is.
|
|
286
|
+
if contrast_ratio(rgb_text, rgb_background) >= target_ratio:
|
|
287
|
+
return tuple(int(c) for c in rgb_text) # type: ignore[return-value]
|
|
288
|
+
|
|
289
|
+
l_bg = relative_luminance(*rgb_background)
|
|
290
|
+
l_text = relative_luminance(*rgb_text)
|
|
291
|
+
|
|
292
|
+
# Decide direction: push text toward black or white.
|
|
293
|
+
# If text is darker than background (l_text < l_bg) → darken further.
|
|
294
|
+
# If text is lighter than background → lighten further.
|
|
295
|
+
should_darken = l_text < l_bg
|
|
296
|
+
|
|
297
|
+
# Binary search on a scalar factor t ∈ [0, 1].
|
|
298
|
+
# t=0 → original text colour, t=1 → black (darken) or white (lighten).
|
|
299
|
+
lo, hi = 0.0, 1.0
|
|
300
|
+
for _ in range(64): # 64 iterations → precision < 1e-18
|
|
301
|
+
mid = (lo + hi) / 2
|
|
302
|
+
if should_darken:
|
|
303
|
+
candidate = tuple(int(c * (1 - mid)) for c in rgb_text)
|
|
304
|
+
else:
|
|
305
|
+
candidate = tuple(int(c + (255 - c) * mid) for c in rgb_text)
|
|
306
|
+
|
|
307
|
+
if contrast_ratio(candidate, rgb_background) >= target_ratio: # type: ignore[arg-type]
|
|
308
|
+
hi = mid
|
|
309
|
+
else:
|
|
310
|
+
lo = mid
|
|
311
|
+
|
|
312
|
+
# Return the result at hi (the least-adjusted value that still passes).
|
|
313
|
+
if should_darken:
|
|
314
|
+
result = tuple(max(0, int(c * (1 - hi))) for c in rgb_text)
|
|
315
|
+
else:
|
|
316
|
+
result = tuple(min(255, int(c + (255 - c) * hi)) for c in rgb_text)
|
|
317
|
+
|
|
318
|
+
return result # type: ignore[return-value]
|