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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pkgprint
3
- Version: 0.1.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
- and bleed/margin calculations useful in print, packaging, and design workflows.
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.1.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]