pkgprint 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
pkgprint/color.py ADDED
@@ -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))
pkgprint/paper.py ADDED
@@ -0,0 +1,174 @@
1
+ """
2
+ paper.py — Standard paper and packaging box sizes.
3
+
4
+ All dimensions are in millimetres (mm).
5
+
6
+ Paper size references:
7
+ - ISO 216 A-series: https://www.iso.org/standard/36631.html
8
+ - ISO 216 B-series: same standard.
9
+ - ANSI paper sizes: ANSI/ASME Y14.1
10
+ - US common sizes (Letter, Legal, Tabloid): industry convention.
11
+
12
+ Box size references:
13
+ - RSC (Regular Slotted Container): TAPPI T 802
14
+ - Mailer / literature mailer: common postal sizing conventions.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Paper sizes (width × height) in mm — portrait orientation
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _PAPER_SIZES: dict[str, tuple[int, int]] = {
24
+ # ISO A-series
25
+ "A0": (841, 1189),
26
+ "A1": (594, 841),
27
+ "A2": (420, 594),
28
+ "A3": (297, 420),
29
+ "A4": (210, 297),
30
+ "A5": (148, 210),
31
+ "A6": (105, 148),
32
+ "A7": (74, 105),
33
+ "A8": (52, 74),
34
+ # ISO B-series
35
+ "B0": (1000, 1414),
36
+ "B1": (707, 1000),
37
+ "B2": (500, 707),
38
+ "B3": (353, 500),
39
+ "B4": (250, 353),
40
+ "B5": (176, 250),
41
+ "B6": (125, 176),
42
+ # ISO C-series (envelope)
43
+ "C4": (229, 324),
44
+ "C5": (162, 229),
45
+ "C6": (114, 162),
46
+ # US / ANSI sizes
47
+ "Letter": (216, 279),
48
+ "Legal": (216, 356),
49
+ "Tabloid": (279, 432),
50
+ "Executive": (184, 267),
51
+ "ANSI A": (216, 279),
52
+ "ANSI B": (279, 432),
53
+ "ANSI C": (432, 559),
54
+ "ANSI D": (559, 864),
55
+ "ANSI E": (864, 1118),
56
+ # Business card sizes — two regional standards exist:
57
+ "Business Card ISO": (85, 55), # ISO 7810 ID-1 / European standard (85×55mm)
58
+ "Business Card US": (89, 51), # North American standard (3.5"×2" = 88.9×50.8mm, rounded)
59
+ # Postcard
60
+ "Postcard": (148, 105),
61
+ # Square social-media-friendly
62
+ "Square Small": (130, 130),
63
+ "Square Large": (210, 210),
64
+ }
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Packaging / box sizes (length × width × height) in mm
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _BOX_SIZES: dict[str, tuple[int, int, int]] = {
71
+ # RSC (Regular Slotted Container) — common e-commerce shipping boxes
72
+ "RSC Small": (200, 150, 100), # ~6L mailer
73
+ "RSC Medium": (305, 229, 152), # ~10L standard shipper
74
+ "RSC Large": (406, 305, 203), # ~25L large shipper
75
+ # Literature / document mailers
76
+ "Mailer A4": (320, 230, 30), # fits A4 flat
77
+ "Mailer A5": (230, 160, 20), # fits A5 flat
78
+ # Gift / retail boxes
79
+ "Gift Small": (150, 100, 50),
80
+ "Gift Medium": (220, 170, 80),
81
+ "Gift Large": (300, 220, 110),
82
+ # Pizza-style tuck-top (common for cosmetics / pharma)
83
+ "Tuck Small": (80, 50, 20),
84
+ "Tuck Medium": (120, 80, 35),
85
+ "Tuck Large": (170, 110, 50),
86
+ }
87
+
88
+
89
+ def paper_size(name: str) -> tuple[int, int]:
90
+ """Return the (width, height) in mm for a named standard paper size.
91
+
92
+ Lookup is **case-insensitive** and ignores leading/trailing whitespace.
93
+
94
+ Args:
95
+ name: Paper size name, e.g. ``"A4"``, ``"letter"``, ``"Legal"``.
96
+
97
+ Returns:
98
+ Tuple of (width, height) in millimetres (portrait orientation).
99
+
100
+ Raises:
101
+ KeyError: If *name* is not a recognised paper size.
102
+
103
+ Example:
104
+ >>> paper_size("A4")
105
+ (210, 297)
106
+ >>> paper_size("letter")
107
+ (216, 279)
108
+ """
109
+ if not isinstance(name, str):
110
+ raise TypeError(f"name must be a string, got {type(name).__name__!r}")
111
+
112
+ # Case-insensitive lookup
113
+ normalised = name.strip()
114
+ for key, dims in _PAPER_SIZES.items():
115
+ if key.lower() == normalised.lower():
116
+ return dims
117
+
118
+ available = ", ".join(sorted(_PAPER_SIZES))
119
+ raise KeyError(
120
+ f"Unknown paper size {name!r}. "
121
+ f"Call list_paper_sizes() to see all options.\nAvailable: {available}"
122
+ )
123
+
124
+
125
+ def list_paper_sizes() -> list[str]:
126
+ """Return a sorted list of all supported paper size names.
127
+
128
+ Returns:
129
+ Alphabetically sorted list of size name strings.
130
+
131
+ Example:
132
+ >>> names = list_paper_sizes()
133
+ >>> "A4" in names
134
+ True
135
+ >>> "Letter" in names
136
+ True
137
+ """
138
+ return sorted(_PAPER_SIZES.keys())
139
+
140
+
141
+ def box_size(style: str) -> tuple[int, int, int]:
142
+ """Return standard packaging box dimensions (L × W × H) in mm.
143
+
144
+ Lookup is **case-insensitive** and ignores leading/trailing whitespace.
145
+
146
+ Args:
147
+ style: Box style name, e.g. ``"RSC Medium"``, ``"Mailer A4"``,
148
+ ``"Gift Large"``.
149
+
150
+ Returns:
151
+ Tuple of (length, width, height) in millimetres.
152
+
153
+ Raises:
154
+ KeyError: If *style* is not a recognised box style.
155
+
156
+ Example:
157
+ >>> box_size("RSC Medium")
158
+ (305, 229, 152)
159
+ >>> box_size("mailer a4")
160
+ (320, 230, 30)
161
+ """
162
+ if not isinstance(style, str):
163
+ raise TypeError(f"style must be a string, got {type(style).__name__!r}")
164
+
165
+ normalised = style.strip()
166
+ for key, dims in _BOX_SIZES.items():
167
+ if key.lower() == normalised.lower():
168
+ return dims
169
+
170
+ available = ", ".join(sorted(_BOX_SIZES))
171
+ raise KeyError(
172
+ f"Unknown box style {style!r}. "
173
+ f"Available styles: {available}"
174
+ )
@@ -0,0 +1,181 @@
1
+ """
2
+ print_specs.py — Print production math: bleed, safe margin, and trim size.
3
+
4
+ All dimensions are in millimetres (mm).
5
+
6
+ Terminology:
7
+ - Bleed: Extra artwork beyond the trim edge, cut away during finishing.
8
+ Ensures colour/image reaches the physical edge with no white gaps.
9
+ Industry default: 3 mm on each side (ISO / offset litho).
10
+ - Trim size: The finished dimensions after cutting.
11
+ - Safe margin: Inner padding inside the trim where critical content should stay,
12
+ to avoid it being accidentally cut or visually too close to the edge.
13
+ Typical recommendation: 3–5 mm inside the trim line.
14
+
15
+ Reference: FOGRA / ISO 12647-2 for offset print bleed conventions.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+
21
+ def _validate_dimension(value: float, name: str) -> None:
22
+ """Raise TypeError/ValueError for invalid dimension values."""
23
+ if not isinstance(value, (int, float)):
24
+ raise TypeError(f"{name!r} must be a number, got {type(value).__name__!r}")
25
+ if value <= 0:
26
+ raise ValueError(f"{name!r} must be positive, got {value}")
27
+
28
+
29
+ def _validate_offset(value: float, name: str) -> None:
30
+ """Raise TypeError/ValueError for invalid offset/margin values."""
31
+ if not isinstance(value, (int, float)):
32
+ raise TypeError(f"{name!r} must be a number, got {type(value).__name__!r}")
33
+ if value < 0:
34
+ raise ValueError(f"{name!r} cannot be negative, got {value}")
35
+
36
+
37
+ def add_bleed(
38
+ width: float,
39
+ height: float,
40
+ bleed_mm: float = 3.0,
41
+ ) -> tuple[float, float]:
42
+ """Return artwork dimensions with bleed added to all four sides.
43
+
44
+ Bleed is added symmetrically: ``bleed_mm`` is added to **each** side,
45
+ so the total increase per axis is ``2 × bleed_mm``.
46
+
47
+ Args:
48
+ width: Trim width in mm (must be positive).
49
+ height: Trim height in mm (must be positive).
50
+ bleed_mm: Bleed amount per side in mm. Default is 3 mm (industry standard).
51
+
52
+ Returns:
53
+ Tuple of (bleed_width, bleed_height) in mm.
54
+
55
+ Raises:
56
+ TypeError: If any argument is not numeric.
57
+ ValueError: If *width* or *height* is not positive, or *bleed_mm* is negative.
58
+
59
+ Example:
60
+ >>> add_bleed(89, 51) # business card with 3 mm bleed
61
+ (95, 57)
62
+ >>> add_bleed(210, 297, bleed_mm=5) # A4 with 5 mm bleed
63
+ (220, 307)
64
+ >>> add_bleed(210, 297, bleed_mm=0) # no bleed
65
+ (210, 297)
66
+ """
67
+ _validate_dimension(width, "width")
68
+ _validate_dimension(height, "height")
69
+ _validate_offset(bleed_mm, "bleed_mm")
70
+
71
+ bleed_width = width + 2 * bleed_mm
72
+ bleed_height = height + 2 * bleed_mm
73
+ # Return int if values are whole numbers, otherwise float
74
+ bleed_width = int(bleed_width) if bleed_width == int(bleed_width) else bleed_width
75
+ bleed_height = int(bleed_height) if bleed_height == int(bleed_height) else bleed_height
76
+ return (bleed_width, bleed_height)
77
+
78
+
79
+ def safe_margin(
80
+ width: float,
81
+ height: float,
82
+ margin_mm: float = 5.0,
83
+ ) -> tuple[float, float]:
84
+ """Return the safe content area after subtracting margins from all sides.
85
+
86
+ The safe area is the inner region where critical content (text, logos)
87
+ should be placed. ``margin_mm`` is subtracted from **each** side, so the
88
+ total reduction per axis is ``2 × margin_mm``.
89
+
90
+ Args:
91
+ width: Trim width in mm (must be positive).
92
+ height: Trim height in mm (must be positive).
93
+ margin_mm: Margin per side in mm. Default is 5 mm.
94
+
95
+ Returns:
96
+ Tuple of (safe_width, safe_height) in mm.
97
+
98
+ Raises:
99
+ TypeError: If any argument is not numeric.
100
+ ValueError: If *width* or *height* is not positive, *margin_mm* is
101
+ negative, or the margin exceeds half the dimension.
102
+
103
+ Example:
104
+ >>> safe_margin(210, 297) # A4 with 5 mm margins
105
+ (200, 287)
106
+ >>> safe_margin(89, 51, margin_mm=3) # business card with 3 mm margins
107
+ (83, 45)
108
+ """
109
+ _validate_dimension(width, "width")
110
+ _validate_dimension(height, "height")
111
+ _validate_offset(margin_mm, "margin_mm")
112
+
113
+ safe_w = width - 2 * margin_mm
114
+ safe_h = height - 2 * margin_mm
115
+
116
+ if safe_w <= 0:
117
+ raise ValueError(
118
+ f"margin_mm={margin_mm} is too large for width={width}: "
119
+ f"safe width would be {safe_w} mm."
120
+ )
121
+ if safe_h <= 0:
122
+ raise ValueError(
123
+ f"margin_mm={margin_mm} is too large for height={height}: "
124
+ f"safe height would be {safe_h} mm."
125
+ )
126
+
127
+ safe_w = int(safe_w) if safe_w == int(safe_w) else safe_w
128
+ safe_h = int(safe_h) if safe_h == int(safe_h) else safe_h
129
+ return (safe_w, safe_h)
130
+
131
+
132
+ def trim_size(
133
+ width: float,
134
+ height: float,
135
+ bleed_mm: float = 3.0,
136
+ ) -> tuple[float, float]:
137
+ """Reverse-calculate trim size from artwork dimensions that include bleed.
138
+
139
+ The inverse of :func:`add_bleed`. Given a canvas/file size that already
140
+ has bleed, return the final trim (finished) dimensions.
141
+
142
+ Args:
143
+ width: Artwork width including bleed, in mm (must be positive).
144
+ height: Artwork height including bleed, in mm (must be positive).
145
+ bleed_mm: Bleed amount per side in mm. Default is 3 mm.
146
+
147
+ Returns:
148
+ Tuple of (trim_width, trim_height) in mm.
149
+
150
+ Raises:
151
+ TypeError: If any argument is not numeric.
152
+ ValueError: If *width* or *height* is not positive, *bleed_mm* is
153
+ negative, or the bleed exceeds the available dimension.
154
+
155
+ Example:
156
+ >>> trim_size(95, 57) # back-calc from business card with bleed
157
+ (89, 51)
158
+ >>> trim_size(220, 307, bleed_mm=5)
159
+ (210, 297)
160
+ """
161
+ _validate_dimension(width, "width")
162
+ _validate_dimension(height, "height")
163
+ _validate_offset(bleed_mm, "bleed_mm")
164
+
165
+ trim_w = width - 2 * bleed_mm
166
+ trim_h = height - 2 * bleed_mm
167
+
168
+ if trim_w <= 0:
169
+ raise ValueError(
170
+ f"bleed_mm={bleed_mm} is too large for width={width}: "
171
+ f"trim width would be {trim_w} mm."
172
+ )
173
+ if trim_h <= 0:
174
+ raise ValueError(
175
+ f"bleed_mm={bleed_mm} is too large for height={height}: "
176
+ f"trim height would be {trim_h} mm."
177
+ )
178
+
179
+ trim_w = int(trim_w) if trim_w == int(trim_w) else trim_w
180
+ trim_h = int(trim_h) if trim_h == int(trim_h) else trim_h
181
+ return (trim_w, trim_h)
pkgprint/units.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ units.py — Measurement unit conversions for print and packaging.
3
+
4
+ Conversion references:
5
+ - 1 inch = 25.4 mm (ISO 1)
6
+ - 1 point = 1/72 inch (PostScript / CSS pt)
7
+ - DPI/PPI are numerically equivalent for these conversions;
8
+ the functions are provided as semantic aliases for clarity.
9
+ """
10
+
11
+
12
+ def mm_to_inches(mm: float) -> float:
13
+ """Convert millimetres to inches.
14
+
15
+ Args:
16
+ mm: Length in millimetres.
17
+
18
+ Returns:
19
+ Equivalent length in inches.
20
+
21
+ Raises:
22
+ TypeError: If *mm* is not a numeric type.
23
+
24
+ Example:
25
+ >>> mm_to_inches(25.4)
26
+ 1.0
27
+ >>> mm_to_inches(210) # A4 width
28
+ 8.267716535433071
29
+ """
30
+ if not isinstance(mm, (int, float)):
31
+ raise TypeError(f"mm must be a number, got {type(mm).__name__!r}")
32
+ return mm / 25.4
33
+
34
+
35
+ def inches_to_mm(inches: float) -> float:
36
+ """Convert inches to millimetres.
37
+
38
+ Args:
39
+ inches: Length in inches.
40
+
41
+ Returns:
42
+ Equivalent length in millimetres.
43
+
44
+ Raises:
45
+ TypeError: If *inches* is not a numeric type.
46
+
47
+ Example:
48
+ >>> inches_to_mm(1.0)
49
+ 25.4
50
+ >>> inches_to_mm(8.5) # US Letter width
51
+ 215.9
52
+ """
53
+ if not isinstance(inches, (int, float)):
54
+ raise TypeError(f"inches must be a number, got {type(inches).__name__!r}")
55
+ return inches * 25.4
56
+
57
+
58
+ def mm_to_points(mm: float) -> float:
59
+ """Convert millimetres to typographic points (PostScript / CSS pt).
60
+
61
+ 1 pt = 1/72 inch = 25.4/72 mm ≈ 0.35278 mm
62
+
63
+ Args:
64
+ mm: Length in millimetres.
65
+
66
+ Returns:
67
+ Equivalent length in points.
68
+
69
+ Raises:
70
+ TypeError: If *mm* is not a numeric type.
71
+
72
+ Example:
73
+ >>> round(mm_to_points(25.4), 4)
74
+ 72.0
75
+ >>> round(mm_to_points(210), 2) # A4 width
76
+ 595.28
77
+ """
78
+ if not isinstance(mm, (int, float)):
79
+ raise TypeError(f"mm must be a number, got {type(mm).__name__!r}")
80
+ return mm * (72 / 25.4)
81
+
82
+
83
+ def points_to_mm(points: float) -> float:
84
+ """Convert typographic points to millimetres.
85
+
86
+ Args:
87
+ points: Length in points (PostScript / CSS pt, 1 pt = 1/72 inch).
88
+
89
+ Returns:
90
+ Equivalent length in millimetres.
91
+
92
+ Raises:
93
+ TypeError: If *points* is not a numeric type.
94
+
95
+ Example:
96
+ >>> round(points_to_mm(72), 4)
97
+ 25.4
98
+ >>> round(points_to_mm(595.28), 2)
99
+ 210.0
100
+ """
101
+ if not isinstance(points, (int, float)):
102
+ raise TypeError(f"points must be a number, got {type(points).__name__!r}")
103
+ return points * (25.4 / 72)
104
+
105
+
106
+ def dpi_to_ppi(dpi: float) -> float:
107
+ """Convert print DPI (dots per inch) to screen PPI (pixels per inch).
108
+
109
+ DPI and PPI share the same unit (measurements per linear inch), so this
110
+ function is a semantic alias that returns the value unchanged. It exists
111
+ to make intent explicit when bridging print and screen workflows.
112
+
113
+ Args:
114
+ dpi: Resolution in dots per inch.
115
+
116
+ Returns:
117
+ Equivalent resolution in pixels per inch.
118
+
119
+ Raises:
120
+ TypeError: If *dpi* is not a numeric type.
121
+ ValueError: If *dpi* is negative.
122
+
123
+ Example:
124
+ >>> dpi_to_ppi(300)
125
+ 300
126
+ >>> dpi_to_ppi(72.0)
127
+ 72.0
128
+ """
129
+ if not isinstance(dpi, (int, float)):
130
+ raise TypeError(f"dpi must be a number, got {type(dpi).__name__!r}")
131
+ if dpi < 0:
132
+ raise ValueError(f"dpi cannot be negative, got {dpi}")
133
+ return dpi
134
+
135
+
136
+ def ppi_to_dpi(ppi: float) -> float:
137
+ """Convert screen PPI (pixels per inch) to print DPI (dots per inch).
138
+
139
+ PPI and DPI share the same unit, so this function is a semantic alias
140
+ that returns the value unchanged. It exists to make intent explicit when
141
+ bridging screen and print workflows.
142
+
143
+ Args:
144
+ ppi: Resolution in pixels per inch.
145
+
146
+ Returns:
147
+ Equivalent resolution in dots per inch.
148
+
149
+ Raises:
150
+ TypeError: If *ppi* is not a numeric type.
151
+ ValueError: If *ppi* is negative.
152
+
153
+ Example:
154
+ >>> ppi_to_dpi(96)
155
+ 96
156
+ >>> ppi_to_dpi(144.0)
157
+ 144.0
158
+ """
159
+ if not isinstance(ppi, (int, float)):
160
+ raise TypeError(f"ppi must be a number, got {type(ppi).__name__!r}")
161
+ if ppi < 0:
162
+ raise ValueError(f"ppi cannot be negative, got {ppi}")
163
+ return ppi
@@ -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,10 @@
1
+ pkgprint/__init__.py,sha256=ulgXTBBGuKYIajYYCaDUVWnv4GJ7GD6Jwr9azFUk8rI,1045
2
+ pkgprint/color.py,sha256=BZ6Xl_Kr7cXOAZJ2hPzFxTVYn2wNmJL5qm0M0mao95c,6278
3
+ pkgprint/paper.py,sha256=PqWh6aUiven9_qES6AyPQd9dK5JmybZNGkgVltpAO8Q,5252
4
+ pkgprint/print_specs.py,sha256=p_7RxHNFlsvfQ5d09oZvey_Vqucvr8Xeul5Jo4uHUuo,6434
5
+ pkgprint/units.py,sha256=wdeRkdypAw2-gbnj21bqHcFSlX1h0SMsk_zS6OyUus0,4292
6
+ pkgprint-0.1.0.dist-info/LICENSE,sha256=NKgQ3-TWgHdRTQX1pYHv12yIXKOQ5_AUxjm_MMGI4LM,1064
7
+ pkgprint-0.1.0.dist-info/METADATA,sha256=6usdQcQ0gu0FiUsOFnIBRVIPJvWOMuFQ-7blt1UzI5o,6156
8
+ pkgprint-0.1.0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
9
+ pkgprint-0.1.0.dist-info/top_level.txt,sha256=BauSMV774J8U8YJnExtUUcw_yjTTe4ngEsrznqEDqzM,9
10
+ pkgprint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pkgprint