This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -4,6 +4,9 @@
# Optional color management support, based on Kevin Cazabon's PyCMS
# library.
# Originally released under LGPL. Graciously donated to PIL in
# March 2009, for distribution under the standard PIL license
# History:
# 2009-03-08 fl Added to PIL.
@@ -14,22 +17,32 @@
# See the README file for information on usage and redistribution. See
# below for the original description.
from __future__ import annotations
import operator
import sys
from enum import IntEnum
from enum import IntEnum, IntFlag
from functools import reduce
from typing import Any, Literal, SupportsFloat, SupportsInt, Union
from . import Image
from ._deprecate import deprecate
from ._typing import SupportsRead
try:
from . import _imagingcms
from . import _imagingcms as core
_CmsProfileCompatible = Union[
str, SupportsRead[bytes], core.CmsProfile, "ImageCmsProfile"
]
except ImportError as ex:
# Allow error import for doc purposes, but error out when accessing
# anything in core.
from ._util import DeferredError
_imagingcms = DeferredError(ex)
core = DeferredError.new(ex)
DESCRIPTION = """
_DESCRIPTION = """
pyCMS
a Python / PIL interface to the littleCMS ICC Color Management System
@@ -92,11 +105,11 @@ pyCMS
"""
VERSION = "1.0.0 pil"
_VERSION = "1.0.0 pil"
# --------------------------------------------------------------------.
core = _imagingcms
#
# intent/direction values
@@ -118,7 +131,70 @@ class Direction(IntEnum):
#
# flags
FLAGS = {
class Flags(IntFlag):
"""Flags and documentation are taken from ``lcms2.h``."""
NONE = 0
NOCACHE = 0x0040
"""Inhibit 1-pixel cache"""
NOOPTIMIZE = 0x0100
"""Inhibit optimizations"""
NULLTRANSFORM = 0x0200
"""Don't transform anyway"""
GAMUTCHECK = 0x1000
"""Out of Gamut alarm"""
SOFTPROOFING = 0x4000
"""Do softproofing"""
BLACKPOINTCOMPENSATION = 0x2000
NOWHITEONWHITEFIXUP = 0x0004
"""Don't fix scum dot"""
HIGHRESPRECALC = 0x0400
"""Use more memory to give better accuracy"""
LOWRESPRECALC = 0x0800
"""Use less memory to minimize resources"""
# this should be 8BITS_DEVICELINK, but that is not a valid name in Python:
USE_8BITS_DEVICELINK = 0x0008
"""Create 8 bits devicelinks"""
GUESSDEVICECLASS = 0x0020
"""Guess device class (for ``transform2devicelink``)"""
KEEP_SEQUENCE = 0x0080
"""Keep profile sequence for devicelink creation"""
FORCE_CLUT = 0x0002
"""Force CLUT optimization"""
CLUT_POST_LINEARIZATION = 0x0001
"""create postlinearization tables if possible"""
CLUT_PRE_LINEARIZATION = 0x0010
"""create prelinearization tables if possible"""
NONEGATIVES = 0x8000
"""Prevent negative numbers in floating point transforms"""
COPY_ALPHA = 0x04000000
"""Alpha channels are copied on ``cmsDoTransform()``"""
NODEFAULTRESOURCEDEF = 0x01000000
_GRIDPOINTS_1 = 1 << 16
_GRIDPOINTS_2 = 2 << 16
_GRIDPOINTS_4 = 4 << 16
_GRIDPOINTS_8 = 8 << 16
_GRIDPOINTS_16 = 16 << 16
_GRIDPOINTS_32 = 32 << 16
_GRIDPOINTS_64 = 64 << 16
_GRIDPOINTS_128 = 128 << 16
@staticmethod
def GRIDPOINTS(n: int) -> Flags:
"""
Fine-tune control over number of gridpoints
:param n: :py:class:`int` in range ``0 <= n <= 255``
"""
return Flags.NONE | ((n & 0xFF) << 16)
_MAX_FLAG = reduce(operator.or_, Flags)
_FLAGS = {
"MATRIXINPUT": 1,
"MATRIXOUTPUT": 2,
"MATRIXONLY": (1 | 2),
@@ -141,11 +217,6 @@ FLAGS = {
"GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints
}
_MAX_FLAG = 0
for flag in FLAGS.values():
if isinstance(flag, int):
_MAX_FLAG = _MAX_FLAG | flag
# --------------------------------------------------------------------.
# Experimental PIL-level API
@@ -156,13 +227,14 @@ for flag in FLAGS.values():
class ImageCmsProfile:
def __init__(self, profile):
def __init__(self, profile: str | SupportsRead[bytes] | core.CmsProfile) -> None:
"""
:param profile: Either a string representing a filename,
a file like object containing a profile or a
low-level profile object
"""
self.filename: str | None = None
if isinstance(profile, str):
if sys.platform == "win32":
@@ -171,24 +243,26 @@ class ImageCmsProfile:
profile_bytes_path.decode("ascii")
except UnicodeDecodeError:
with open(profile, "rb") as f:
self._set(core.profile_frombytes(f.read()))
self.profile = core.profile_frombytes(f.read())
return
self._set(core.profile_open(profile), profile)
self.filename = profile
self.profile = core.profile_open(profile)
elif hasattr(profile, "read"):
self._set(core.profile_frombytes(profile.read()))
elif isinstance(profile, _imagingcms.CmsProfile):
self._set(profile)
self.profile = core.profile_frombytes(profile.read())
elif isinstance(profile, core.CmsProfile):
self.profile = profile
else:
msg = "Invalid type for Profile"
msg = "Invalid type for Profile" # type: ignore[unreachable]
raise TypeError(msg)
def _set(self, profile, filename=None):
self.profile = profile
self.filename = filename
self.product_name = None # profile.product_name
self.product_info = None # profile.product_info
def __getattr__(self, name: str) -> Any:
if name in ("product_name", "product_info"):
deprecate(f"ImageCms.ImageCmsProfile.{name}", 13)
return None
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
raise AttributeError(msg)
def tobytes(self):
def tobytes(self) -> bytes:
"""
Returns the profile in a format suitable for embedding in
saved images.
@@ -200,7 +274,6 @@ class ImageCmsProfile:
class ImageCmsTransform(Image.ImagePointHandler):
"""
Transform. This can be used with the procedural API, or with the standard
:py:func:`~PIL.Image.Image.point` method.
@@ -210,14 +283,14 @@ class ImageCmsTransform(Image.ImagePointHandler):
def __init__(
self,
input,
output,
input_mode,
output_mode,
intent=Intent.PERCEPTUAL,
proof=None,
proof_intent=Intent.ABSOLUTE_COLORIMETRIC,
flags=0,
input: ImageCmsProfile,
output: ImageCmsProfile,
input_mode: str,
output_mode: str,
intent: Intent = Intent.PERCEPTUAL,
proof: ImageCmsProfile | None = None,
proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC,
flags: Flags = Flags.NONE,
):
if proof is None:
self.transform = core.buildTransform(
@@ -240,28 +313,26 @@ class ImageCmsTransform(Image.ImagePointHandler):
self.output_profile = output
def point(self, im):
def point(self, im: Image.Image) -> Image.Image:
return self.apply(im)
def apply(self, im, imOut=None):
im.load()
def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image:
if imOut is None:
imOut = Image.new(self.output_mode, im.size, None)
self.transform.apply(im.im.id, imOut.im.id)
self.transform.apply(im.getim(), imOut.getim())
imOut.info["icc_profile"] = self.output_profile.tobytes()
return imOut
def apply_in_place(self, im):
im.load()
def apply_in_place(self, im: Image.Image) -> Image.Image:
if im.mode != self.output_mode:
msg = "mode mismatch"
raise ValueError(msg) # wrong output mode
self.transform.apply(im.im.id, im.im.id)
self.transform.apply(im.getim(), im.getim())
im.info["icc_profile"] = self.output_profile.tobytes()
return im
def get_display_profile(handle=None):
def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | None:
"""
(experimental) Fetches the profile for the current display device.
@@ -271,12 +342,12 @@ def get_display_profile(handle=None):
if sys.platform != "win32":
return None
from . import ImageWin
from . import ImageWin # type: ignore[unused-ignore, unreachable]
if isinstance(handle, ImageWin.HDC):
profile = core.get_display_profile_win32(handle, 1)
profile = core.get_display_profile_win32(int(handle), 1)
else:
profile = core.get_display_profile_win32(handle or 0)
profile = core.get_display_profile_win32(int(handle or 0))
if profile is None:
return None
return ImageCmsProfile(profile)
@@ -288,7 +359,6 @@ def get_display_profile(handle=None):
class PyCMSError(Exception):
"""(pyCMS) Exception class.
This is used for all errors in the pyCMS API."""
@@ -296,14 +366,14 @@ class PyCMSError(Exception):
def profileToProfile(
im,
inputProfile,
outputProfile,
renderingIntent=Intent.PERCEPTUAL,
outputMode=None,
inPlace=False,
flags=0,
):
im: Image.Image,
inputProfile: _CmsProfileCompatible,
outputProfile: _CmsProfileCompatible,
renderingIntent: Intent = Intent.PERCEPTUAL,
outputMode: str | None = None,
inPlace: bool = False,
flags: Flags = Flags.NONE,
) -> Image.Image | None:
"""
(pyCMS) Applies an ICC transformation to a given image, mapping from
``inputProfile`` to ``outputProfile``.
@@ -391,7 +461,9 @@ def profileToProfile(
return imOut
def getOpenProfile(profileFilename):
def getOpenProfile(
profileFilename: str | SupportsRead[bytes] | core.CmsProfile,
) -> ImageCmsProfile:
"""
(pyCMS) Opens an ICC profile file.
@@ -414,13 +486,13 @@ def getOpenProfile(profileFilename):
def buildTransform(
inputProfile,
outputProfile,
inMode,
outMode,
renderingIntent=Intent.PERCEPTUAL,
flags=0,
):
inputProfile: _CmsProfileCompatible,
outputProfile: _CmsProfileCompatible,
inMode: str,
outMode: str,
renderingIntent: Intent = Intent.PERCEPTUAL,
flags: Flags = Flags.NONE,
) -> ImageCmsTransform:
"""
(pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the
``outputProfile``. Use applyTransform to apply the transform to a given
@@ -481,7 +553,7 @@ def buildTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
msg = "flags must be an integer between 0 and %s" + _MAX_FLAG
msg = f"flags must be an integer between 0 and {_MAX_FLAG}"
raise PyCMSError(msg)
try:
@@ -497,15 +569,15 @@ def buildTransform(
def buildProofTransform(
inputProfile,
outputProfile,
proofProfile,
inMode,
outMode,
renderingIntent=Intent.PERCEPTUAL,
proofRenderingIntent=Intent.ABSOLUTE_COLORIMETRIC,
flags=FLAGS["SOFTPROOFING"],
):
inputProfile: _CmsProfileCompatible,
outputProfile: _CmsProfileCompatible,
proofProfile: _CmsProfileCompatible,
inMode: str,
outMode: str,
renderingIntent: Intent = Intent.PERCEPTUAL,
proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC,
flags: Flags = Flags.SOFTPROOFING,
) -> ImageCmsTransform:
"""
(pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the
``outputProfile``, but tries to simulate the result that would be
@@ -585,7 +657,7 @@ def buildProofTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
msg = "flags must be an integer between 0 and %s" + _MAX_FLAG
msg = f"flags must be an integer between 0 and {_MAX_FLAG}"
raise PyCMSError(msg)
try:
@@ -613,16 +685,18 @@ buildTransformFromOpenProfiles = buildTransform
buildProofTransformFromOpenProfiles = buildProofTransform
def applyTransform(im, transform, inPlace=False):
def applyTransform(
im: Image.Image, transform: ImageCmsTransform, inPlace: bool = False
) -> Image.Image | None:
"""
(pyCMS) Applies a transform to a given image.
If ``im.mode != transform.inMode``, a :exc:`PyCMSError` is raised.
If ``im.mode != transform.input_mode``, a :exc:`PyCMSError` is raised.
If ``inPlace`` is ``True`` and ``transform.inMode != transform.outMode``, a
If ``inPlace`` is ``True`` and ``transform.input_mode != transform.output_mode``, a
:exc:`PyCMSError` is raised.
If ``im.mode``, ``transform.inMode`` or ``transform.outMode`` is not
If ``im.mode``, ``transform.input_mode`` or ``transform.output_mode`` is not
supported by pyCMSdll or the profiles you used for the transform, a
:exc:`PyCMSError` is raised.
@@ -636,13 +710,13 @@ def applyTransform(im, transform, inPlace=False):
If you want to modify im in-place instead of receiving a new image as
the return value, set ``inPlace`` to ``True``. This can only be done if
``transform.inMode`` and ``transform.outMode`` are the same, because we can't
change the mode in-place (the buffer sizes for some modes are
``transform.input_mode`` and ``transform.output_mode`` are the same, because we
can't change the mode in-place (the buffer sizes for some modes are
different). The default behavior is to return a new :py:class:`~PIL.Image.Image`
object of the same dimensions in mode ``transform.outMode``.
object of the same dimensions in mode ``transform.output_mode``.
:param im: An :py:class:`~PIL.Image.Image` object, and im.mode must be the same
as the ``inMode`` supported by the transform.
:param im: An :py:class:`~PIL.Image.Image` object, and ``im.mode`` must be the same
as the ``input_mode`` supported by the transform.
:param transform: A valid CmsTransform class object
:param inPlace: Bool. If ``True``, ``im`` is modified in place and ``None`` is
returned, if ``False``, a new :py:class:`~PIL.Image.Image` object with the
@@ -666,7 +740,9 @@ def applyTransform(im, transform, inPlace=False):
return imOut
def createProfile(colorSpace, colorTemp=-1):
def createProfile(
colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0
) -> core.CmsProfile:
"""
(pyCMS) Creates a profile.
@@ -688,7 +764,7 @@ def createProfile(colorSpace, colorTemp=-1):
:param colorSpace: String, the color space of the profile you wish to
create.
Currently only "LAB", "XYZ", and "sRGB" are supported.
:param colorTemp: Positive integer for the white point for the profile, in
:param colorTemp: Positive number for the white point for the profile, in
degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50
illuminant if omitted (5000k). colorTemp is ONLY applied to LAB
profiles, and is ignored for XYZ and sRGB.
@@ -715,7 +791,7 @@ def createProfile(colorSpace, colorTemp=-1):
raise PyCMSError(v) from v
def getProfileName(profile):
def getProfileName(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the internal product name for the given profile.
@@ -749,15 +825,15 @@ def getProfileName(profile):
if not (model or manufacturer):
return (profile.profile.profile_description or "") + "\n"
if not manufacturer or len(model) > 30:
return model + "\n"
if not manufacturer or (model and len(model) > 30):
return f"{model}\n"
return f"{model} - {manufacturer}\n"
except (AttributeError, OSError, TypeError, ValueError) as v:
raise PyCMSError(v) from v
def getProfileInfo(profile):
def getProfileInfo(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the internal product information for the given profile.
@@ -787,17 +863,14 @@ def getProfileInfo(profile):
# info was description \r\n\r\n copyright \r\n\r\n K007 tag \r\n\r\n whitepoint
description = profile.profile.profile_description
cpright = profile.profile.copyright
arr = []
for elt in (description, cpright):
if elt:
arr.append(elt)
return "\r\n\r\n".join(arr) + "\r\n\r\n"
elements = [element for element in (description, cpright) if element]
return "\r\n\r\n".join(elements) + "\r\n\r\n"
except (AttributeError, OSError, TypeError, ValueError) as v:
raise PyCMSError(v) from v
def getProfileCopyright(profile):
def getProfileCopyright(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the copyright for the given profile.
@@ -825,7 +898,7 @@ def getProfileCopyright(profile):
raise PyCMSError(v) from v
def getProfileManufacturer(profile):
def getProfileManufacturer(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the manufacturer for the given profile.
@@ -853,7 +926,7 @@ def getProfileManufacturer(profile):
raise PyCMSError(v) from v
def getProfileModel(profile):
def getProfileModel(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the model for the given profile.
@@ -882,7 +955,7 @@ def getProfileModel(profile):
raise PyCMSError(v) from v
def getProfileDescription(profile):
def getProfileDescription(profile: _CmsProfileCompatible) -> str:
"""
(pyCMS) Gets the description for the given profile.
@@ -911,7 +984,7 @@ def getProfileDescription(profile):
raise PyCMSError(v) from v
def getDefaultIntent(profile):
def getDefaultIntent(profile: _CmsProfileCompatible) -> int:
"""
(pyCMS) Gets the default intent name for the given profile.
@@ -950,7 +1023,9 @@ def getDefaultIntent(profile):
raise PyCMSError(v) from v
def isIntentSupported(profile, intent, direction):
def isIntentSupported(
profile: _CmsProfileCompatible, intent: Intent, direction: Direction
) -> Literal[-1, 1]:
"""
(pyCMS) Checks if a given intent is supported.
@@ -999,11 +1074,3 @@ def isIntentSupported(profile, intent, direction):
return -1
except (AttributeError, OSError, TypeError, ValueError) as v:
raise PyCMSError(v) from v
def versions():
"""
(pyCMS) Fetches versions.
"""
return VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__