iccdev-mcp 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.
iccdev_mcp/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # iccdev-mcp -- MCP server for ICC color profile tools
2
+ #
3
+ # Copyright (c) International Color Consortium.
4
+ # BSD 3-Clause License. See LICENSE.md for details.
5
+
6
+ """
7
+ MCP server exposing iccDEV ICC color profile tools to AI assistants.
8
+
9
+ Provides profile tooling through:
10
+ - Python-native (header inspection, validation, color transforms, enums)
11
+ - subprocess-backed (dump, XML/JSON conversion, image I/O)
12
+ - service tools (health and profile discovery)
13
+
14
+ Quick start::
15
+
16
+ # Install
17
+ pip install "git+https://github.com/InternationalColorConsortium/iccDEV.git#subdirectory=iccdev-mcp"
18
+ # Or install the universal wheel URL from a successful GitHub release.
19
+
20
+ # Run MCP server (stdio transport for Claude Desktop / VS Code)
21
+ iccdev-mcp
22
+
23
+ # Run with SSE transport for remote access
24
+ iccdev-mcp --transport sse --port 8080
25
+ """
26
+
27
+ __version__ = "0.1.0"
iccdev_mcp/_native.py ADDED
@@ -0,0 +1,372 @@
1
+ # iccdev-mcp -- Python-native ICC helpers
2
+ #
3
+ # Copyright (c) International Color Consortium.
4
+ # BSD 3-Clause License. See LICENSE.md for details.
5
+
6
+ """Small Python-native ICC profile interface used by iccdev-mcp.
7
+
8
+ This module parses ICC headers and loads the public C validation ABI when
9
+ available. Color transforms require the separate native iccdev bindings.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ctypes
15
+ from dataclasses import asdict, dataclass
16
+ from enum import IntEnum
17
+ from functools import lru_cache
18
+ import os
19
+ from pathlib import Path
20
+ import struct
21
+ from typing import NamedTuple
22
+
23
+ _HEADER_SIZE = 128
24
+
25
+
26
+ def _sig(value: str) -> int:
27
+ return int.from_bytes(value.encode("ascii"), "big")
28
+
29
+
30
+ def sig_to_str(signature: int) -> str:
31
+ """Convert a 32-bit ICC signature integer to a 4-character string."""
32
+ if not isinstance(signature, int):
33
+ raise TypeError("signature must be an integer")
34
+ if signature < 0 or signature > 0xFFFFFFFF:
35
+ raise ValueError("signature must fit in 32 bits")
36
+ return signature.to_bytes(4, "big").decode("latin-1")
37
+
38
+
39
+ class RenderingIntent(IntEnum):
40
+ Perceptual = 0
41
+ RelativeColorimetric = 1
42
+ Saturation = 2
43
+ AbsoluteColorimetric = 3
44
+
45
+
46
+ class Interpolation(IntEnum):
47
+ Linear = 0
48
+ Tetrahedral = 1
49
+
50
+
51
+ class ValidationStatus(IntEnum):
52
+ OK = 0
53
+ WARNING = 1
54
+ NON_COMPLIANT = 2
55
+ CRITICAL_ERROR = 3
56
+ INVALID_ARGUMENT = 4
57
+ INTERNAL_ERROR = 5
58
+
59
+
60
+ class ValidationResult(NamedTuple):
61
+ status: ValidationStatus
62
+ report: str
63
+
64
+
65
+ def _validation_library_path() -> Path:
66
+ """Find a shared IccProfLib containing the public validation ABI."""
67
+ configured = os.environ.get("ICCDEV_VALIDATION_LIBRARY")
68
+ if configured is not None:
69
+ path = Path(configured)
70
+ if path.is_file():
71
+ return path
72
+ raise RuntimeError(
73
+ "ICCDEV_VALIDATION_LIBRARY does not name a shared IccProfLib"
74
+ )
75
+
76
+ build_dir = os.environ.get("ICCDEV_BUILD_DIR")
77
+ if build_dir:
78
+ library_dir = Path(build_dir) / "IccProfLib"
79
+ for pattern in ("libIccProfLib2*.so*", "libIccProfLib2*.dylib", "IccProfLib2*.dll"):
80
+ matches = sorted(path for path in library_dir.glob(pattern) if path.is_file())
81
+ if matches:
82
+ return matches[0]
83
+
84
+ raise RuntimeError(
85
+ "Native validation requires ICCDEV_VALIDATION_LIBRARY or "
86
+ "ICCDEV_BUILD_DIR pointing to a shared IccProfLib build"
87
+ )
88
+
89
+
90
+ @lru_cache(maxsize=None)
91
+ def _validation_function(library_path: str):
92
+ """Load the C ABI once per resolved shared-library path."""
93
+ try:
94
+ function = ctypes.CDLL(library_path).icc_validate_profile
95
+ except OSError as exc:
96
+ raise RuntimeError(f"Unable to load IccProfLib: {exc}") from exc
97
+ except AttributeError as exc:
98
+ raise RuntimeError(
99
+ "IccProfLib does not export icc_validate_profile"
100
+ ) from exc
101
+
102
+ function.argtypes = (
103
+ ctypes.c_void_p,
104
+ ctypes.c_size_t,
105
+ ctypes.c_void_p,
106
+ ctypes.c_size_t,
107
+ )
108
+ function.restype = ctypes.c_int
109
+ return function
110
+
111
+
112
+ def native_validation_available() -> bool:
113
+ """Return whether the public C validation ABI can be loaded."""
114
+ try:
115
+ _validation_function(str(_validation_library_path()))
116
+ except RuntimeError:
117
+ return False
118
+ return True
119
+
120
+
121
+ def validate_profile(profile: bytes | bytearray | memoryview) -> ValidationResult:
122
+ """Validate bytes through the public in-process C validation ABI."""
123
+ if not isinstance(profile, (bytes, bytearray, memoryview)):
124
+ raise TypeError("profile must be bytes, bytearray, or memoryview")
125
+
126
+ profile_data = bytes(profile)
127
+ data_buffer = ctypes.create_string_buffer(profile_data) if profile_data else None
128
+ report_buffer = ctypes.create_string_buffer(8192)
129
+ status = _validation_function(str(_validation_library_path()))(
130
+ ctypes.cast(data_buffer, ctypes.c_void_p) if data_buffer else None,
131
+ len(profile_data),
132
+ report_buffer,
133
+ len(report_buffer),
134
+ )
135
+ try:
136
+ validation_status = ValidationStatus(status)
137
+ except ValueError:
138
+ validation_status = ValidationStatus.INTERNAL_ERROR
139
+ return ValidationResult(
140
+ validation_status,
141
+ report_buffer.value.decode("utf-8", errors="replace"),
142
+ )
143
+
144
+
145
+ def validate_profile_file(path: str | Path) -> ValidationResult:
146
+ """Read a profile and validate it through :func:`validate_profile`."""
147
+ return validate_profile(Path(path).read_bytes())
148
+
149
+
150
+ class ColorSpace(IntEnum):
151
+ XYZ = _sig("XYZ ")
152
+ Lab = _sig("Lab ")
153
+ Luv = _sig("Luv ")
154
+ YCbCr = _sig("YCbr")
155
+ Yxy = _sig("Yxy ")
156
+ RGB = _sig("RGB ")
157
+ GRAY = _sig("GRAY")
158
+ HSV = _sig("HSV ")
159
+ HLS = _sig("HLS ")
160
+ CMYK = _sig("CMYK")
161
+ CMY = _sig("CMY ")
162
+ MCH1 = _sig("MCH1")
163
+ MCH2 = _sig("MCH2")
164
+ MCH3 = _sig("MCH3")
165
+ MCH4 = _sig("MCH4")
166
+ MCH5 = _sig("MCH5")
167
+ MCH6 = _sig("MCH6")
168
+ MCH7 = _sig("MCH7")
169
+ MCH8 = _sig("MCH8")
170
+ MCH9 = _sig("MCH9")
171
+ MCHA = _sig("MCHA")
172
+ MCHB = _sig("MCHB")
173
+ MCHC = _sig("MCHC")
174
+ MCHD = _sig("MCHD")
175
+ MCHE = _sig("MCHE")
176
+ MCHF = _sig("MCHF")
177
+ Named = _sig("nmcl")
178
+ One = _sig("1CLR")
179
+ Two = _sig("2CLR")
180
+ Three = _sig("3CLR")
181
+ Four = _sig("4CLR")
182
+ Five = _sig("5CLR")
183
+
184
+
185
+ _COLOR_SPACE_NAMES = {
186
+ "XYZ ": "XYZ",
187
+ "Lab ": "Lab",
188
+ "Luv ": "Luv",
189
+ "YCbr": "YCbCr",
190
+ "Yxy ": "Yxy",
191
+ "RGB ": "RGB",
192
+ "GRAY": "Gray",
193
+ "HSV ": "HSV",
194
+ "HLS ": "HLS",
195
+ "CMYK": "CMYK",
196
+ "CMY ": "CMY",
197
+ "nmcl": "Named",
198
+ }
199
+
200
+ _DEVICE_CLASS_NAMES = {
201
+ "scnr": "Input",
202
+ "mntr": "Display",
203
+ "prtr": "Output",
204
+ "link": "DeviceLink",
205
+ "spac": "ColorSpace",
206
+ "abst": "Abstract",
207
+ "nmcl": "NamedColor",
208
+ }
209
+
210
+ _PLATFORM_NAMES = {
211
+ "APPL": "Apple",
212
+ "MSFT": "Microsoft",
213
+ "SGI ": "Silicon Graphics",
214
+ "SUNW": "Sun",
215
+ }
216
+
217
+ _RENDERING_INTENT_NAMES = {
218
+ 0: "Perceptual",
219
+ 1: "Media-Relative Colorimetric",
220
+ 2: "Saturation",
221
+ 3: "ICC-Absolute Colorimetric",
222
+ }
223
+
224
+ _CHANNEL_COUNTS = {
225
+ "XYZ ": 3,
226
+ "Lab ": 3,
227
+ "Luv ": 3,
228
+ "YCbr": 3,
229
+ "Yxy ": 3,
230
+ "RGB ": 3,
231
+ "GRAY": 1,
232
+ "HSV ": 3,
233
+ "HLS ": 3,
234
+ "CMYK": 4,
235
+ "CMY ": 3,
236
+ "nmcl": 1,
237
+ }
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class IccHeader:
242
+ size: int
243
+ cmm_type: str
244
+ version: int
245
+ device_class: str
246
+ color_space: str
247
+ pcs: str
248
+ date_time: tuple[int, int, int, int, int, int]
249
+ magic: str
250
+ platform: str
251
+ flags: int
252
+ manufacturer: str
253
+ model: str
254
+ attributes: int
255
+ rendering_intent: int
256
+ illuminant: tuple[float, float, float]
257
+ creator: str
258
+ profile_id: bytes
259
+ color_space_name: str
260
+ pcs_name: str
261
+ device_class_name: str
262
+ version_string: str
263
+ rendering_intent_name: str
264
+ platform_name: str
265
+
266
+ def _asdict(self) -> dict:
267
+ return asdict(self)
268
+
269
+
270
+ class IccProfile:
271
+ """Parsed ICC profile with header metadata."""
272
+
273
+ def __init__(self, path: str | Path):
274
+ self.path = Path(path)
275
+ data = self.path.read_bytes()
276
+ if len(data) < _HEADER_SIZE:
277
+ raise ValueError("ICC profile is shorter than the 128-byte header")
278
+ self.header = _parse_header(data[:_HEADER_SIZE])
279
+ self.is_v5 = ((self.header.version >> 24) & 0xFF) >= 5
280
+
281
+ def close(self) -> None:
282
+ return None
283
+
284
+
285
+ def open_profile(path: str | Path) -> IccProfile:
286
+ """Open and parse an ICC profile header."""
287
+ return IccProfile(path)
288
+
289
+
290
+ def _parse_header(data: bytes) -> IccHeader:
291
+ size = _u32(data, 0)
292
+ cmm_type = _ascii_sig(data, 4)
293
+ version = _u32(data, 8)
294
+ device_class = _ascii_sig(data, 12)
295
+ color_space = _ascii_sig(data, 16)
296
+ pcs = _ascii_sig(data, 20)
297
+ date_time = struct.unpack(">6H", data[24:36])
298
+ magic = _ascii_sig(data, 36)
299
+ if magic != "acsp":
300
+ raise ValueError("ICC header magic is not acsp")
301
+ platform = _ascii_sig(data, 40)
302
+ flags = _u32(data, 44)
303
+ manufacturer = _ascii_sig(data, 48)
304
+ model = _ascii_sig(data, 52)
305
+ attributes = struct.unpack(">Q", data[56:64])[0]
306
+ rendering_intent = _u32(data, 64)
307
+ illuminant = (
308
+ _s15_fixed16(data, 68),
309
+ _s15_fixed16(data, 72),
310
+ _s15_fixed16(data, 76),
311
+ )
312
+ creator = _ascii_sig(data, 80)
313
+ profile_id = data[84:100]
314
+ return IccHeader(
315
+ size=size,
316
+ cmm_type=cmm_type,
317
+ version=version,
318
+ device_class=device_class,
319
+ color_space=color_space,
320
+ pcs=pcs,
321
+ date_time=date_time,
322
+ magic=magic,
323
+ platform=platform,
324
+ flags=flags,
325
+ manufacturer=manufacturer,
326
+ model=model,
327
+ attributes=attributes,
328
+ rendering_intent=rendering_intent,
329
+ illuminant=illuminant,
330
+ creator=creator,
331
+ profile_id=profile_id,
332
+ color_space_name=_COLOR_SPACE_NAMES.get(color_space, color_space.strip()),
333
+ pcs_name=_COLOR_SPACE_NAMES.get(pcs, pcs.strip()),
334
+ device_class_name=_DEVICE_CLASS_NAMES.get(device_class, device_class.strip()),
335
+ version_string=_version_string(version),
336
+ rendering_intent_name=_RENDERING_INTENT_NAMES.get(
337
+ rendering_intent, str(rendering_intent)
338
+ ),
339
+ platform_name=_PLATFORM_NAMES.get(platform, platform.strip()),
340
+ )
341
+
342
+
343
+ def _ascii_sig(data: bytes, offset: int) -> str:
344
+ return data[offset:offset + 4].decode("latin-1")
345
+
346
+
347
+ def _u32(data: bytes, offset: int) -> int:
348
+ return struct.unpack(">I", data[offset:offset + 4])[0]
349
+
350
+
351
+ def _s15_fixed16(data: bytes, offset: int) -> float:
352
+ return struct.unpack(">i", data[offset:offset + 4])[0] / 65536.0
353
+
354
+
355
+ def _version_string(version: int) -> str:
356
+ major = (version >> 24) & 0xFF
357
+ minor = (version >> 20) & 0x0F
358
+ bugfix = (version >> 16) & 0x0F
359
+ return f"{major}.{minor}.{bugfix}"
360
+
361
+
362
+ def _channel_count(color_space: str) -> int:
363
+ if color_space in _CHANNEL_COUNTS:
364
+ return _CHANNEL_COUNTS[color_space]
365
+ if len(color_space) == 4 and color_space[1:] == "CLR" and color_space[0].isdigit():
366
+ return int(color_space[0])
367
+ if len(color_space) == 4 and color_space.startswith("MCH"):
368
+ suffix = color_space[3]
369
+ if suffix.isdigit():
370
+ return int(suffix)
371
+ return 10 + ord(suffix.upper()) - ord("A")
372
+ return 3