Merge branch 'main' into jpeg2000_l

This commit is contained in:
Andrew Murray 2026-03-21 01:10:35 +11:00
commit 77df8a36c1
14 changed files with 213 additions and 171 deletions

View File

@ -452,6 +452,7 @@ def test_pclr() -> None:
) as im:
assert im.mode == "P"
assert im.palette is not None
assert im.palette.mode == "CMYK"
assert len(im.palette.colors) == 139
assert im.palette.colors[(0, 0, 0, 0)] == 0

View File

@ -707,6 +707,16 @@ class TestFilePng:
assert reloaded.png.im_palette is not None
assert len(reloaded.png.im_palette[1]) == 3
def test_plte_cmyk(self, tmp_path: Path) -> None:
im = Image.new("P", (1, 1))
im.putpalette((0, 100, 150, 200), "CMYK")
out = tmp_path / "temp.png"
im.save(out)
with Image.open(out) as reloaded:
assert reloaded.convert("CMYK").getpixel((0, 0)) == (200, 222, 232, 0)
def test_getxmp(self) -> None:
with Image.open("Tests/images/color_snakes.png") as im:
if ElementTree is None:

View File

@ -13,8 +13,6 @@ _TGA_DIR = os.path.join("Tests", "images", "tga")
_TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common")
_ORIGINS = ("tl", "bl")
_ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
@ -29,7 +27,7 @@ _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
("200x32", "RGBA"),
),
)
@pytest.mark.parametrize("origin", _ORIGINS)
@pytest.mark.parametrize("origin", _ORIGIN_TO_ORIENTATION)
@pytest.mark.parametrize("rle", (True, False))
def test_sanity(
size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path

View File

@ -1,10 +1,8 @@
from __future__ import annotations
import pytest
from PIL import Image, ImageDraw, ImageFont
from .helper import skip_unless_feature
from .helper import skip_unless_feature_version
class TestFontCrash:
@ -18,8 +16,7 @@ class TestFontCrash:
draw.multiline_textbbox((10, 10), "ABC\nAaaa", font, stroke_width=2)
draw.text((10, 10), "Test Text", font=font, fill="#000")
@skip_unless_feature("freetype2")
@skip_unless_feature_version("freetype2", "2.12.0")
def test_segfault(self) -> None:
with pytest.raises(OSError):
font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784")
self._fuzz_font(font)
font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784")
self._fuzz_font(font)

View File

@ -91,6 +91,21 @@ def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None:
assert im.palette.colors == {(1, 2, 3, 4): 0}
@pytest.mark.parametrize(
"mode, palette",
(
("CMYK", (1, 2, 3, 4)),
("CMYKX", (1, 2, 3, 4, 0)),
),
)
def test_cmyk_palette(mode: str, palette: tuple[int, ...]) -> None:
im = Image.new("P", (1, 1))
im.putpalette(palette, mode)
assert im.getpalette() == [250, 249, 248]
assert im.palette is not None
assert im.palette.colors == {(1, 2, 3, 4): 0}
def test_empty_palette() -> None:
im = Image.new("P", (1, 1))
assert im.getpalette() == []

View File

@ -885,7 +885,7 @@ class Image:
# unpack data
e = _getencoder(self.mode, encoder_name, encoder_args)
e.setimage(self.im)
e.setimage(self.im, (0, 0) + self.size)
from . import ImageFile
@ -956,7 +956,7 @@ class Image:
# unpack data
d = _getdecoder(self.mode, decoder_name, decoder_args)
d.setimage(self.im)
d.setimage(self.im, (0, 0) + self.size)
s = d.decode(data)
if s[0] >= 0:
@ -2145,8 +2145,8 @@ class Image:
Alternatively, an 8-bit string may be used instead of an integer sequence.
:param data: A palette sequence (either a list or a string).
:param rawmode: The raw mode of the palette. Either "RGB", "RGBA", or a mode
that can be transformed to "RGB" or "RGBA" (e.g. "R", "BGR;15", "RGBA;L").
:param rawmode: The raw mode of the palette. Either "RGB", "RGBA", "CMYK", or a
mode that can be transformed to one of those modes (e.g. "R", "RGBA;L").
"""
from . import ImagePalette
@ -2165,7 +2165,12 @@ class Image:
palette = ImagePalette.raw(rawmode, data)
self._mode = "PA" if "A" in self.mode else "P"
self.palette = palette
self.palette.mode = "RGBA" if "A" in rawmode else "RGB"
if rawmode.startswith("CMYK"):
self.palette.mode = "CMYK"
elif "A" in rawmode:
self.palette.mode = "RGBA"
else:
self.palette.mode = "RGB"
self.load() # install new palette
def putpixel(

View File

@ -203,6 +203,7 @@ def _parse_jp2_header(
if enumcs in (0, 15):
colr = "1"
elif enumcs == 12:
colr = "CMYK"
if nc == 4:
mode = "CMYK"
elif enumcs == 17:
@ -217,7 +218,11 @@ def _parse_jp2_header(
if bitdepth > max_bitdepth:
max_bitdepth = bitdepth
if max_bitdepth <= 8:
palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB")
if npc == 4:
palette_mode = "CMYK" if colr == "CMYK" else "RGBA"
else:
palette_mode = "RGB"
palette = ImagePalette.ImagePalette(palette_mode)
for i in range(ne):
color: list[int] = []
for value in header.read_fields(">" + ("B" * npc)):

View File

@ -1353,6 +1353,9 @@ def _save(
mode = im.mode
outmode = mode
palette = []
if im.palette:
palette = im.getpalette() or []
if mode == "P":
#
# attempt to minimize storage requirements for palette images
@ -1362,7 +1365,7 @@ def _save(
else:
# check palette contents
if im.palette:
colors = max(min(len(im.palette.getdata()[1]) // 3, 256), 1)
colors = max(min(len(palette) // 3, 256), 1)
else:
colors = 256
@ -1435,7 +1438,7 @@ def _save(
if im.mode == "P":
palette_byte_number = colors * 3
palette_bytes = im.im.getpalette("RGB")[:palette_byte_number]
palette_bytes = bytes(palette[:palette_byte_number])
while len(palette_bytes) < palette_byte_number:
palette_bytes += b"\0"
chunk(fp, b"PLTE", palette_bytes)

View File

@ -1042,130 +1042,99 @@ font_render(FontObject *self, PyObject *args) {
yy = -(py + glyph_slot->bitmap_top);
}
// Null buffer, is dereferenced in FT_Bitmap_Convert
if (!bitmap.buffer && bitmap.rows) {
PyErr_SetString(PyExc_OSError, "Bitmap missing for glyph");
goto glyph_error;
}
/* convert non-8bpp bitmaps */
switch (bitmap.pixel_mode) {
case FT_PIXEL_MODE_MONO:
convert_scale = 255;
break;
case FT_PIXEL_MODE_GRAY2:
convert_scale = 255 / 3;
break;
case FT_PIXEL_MODE_GRAY4:
convert_scale = 255 / 15;
break;
default:
convert_scale = 1;
}
switch (bitmap.pixel_mode) {
case FT_PIXEL_MODE_MONO:
case FT_PIXEL_MODE_GRAY2:
case FT_PIXEL_MODE_GRAY4:
if (!bitmap_converted_ready) {
FT_Bitmap_Init(&bitmap_converted);
bitmap_converted_ready = 1;
}
error = FT_Bitmap_Convert(library, &bitmap, &bitmap_converted, 1);
if (error) {
geterror(error);
goto glyph_error;
}
bitmap = bitmap_converted;
/* bitmap is now FT_PIXEL_MODE_GRAY, fall through */
case FT_PIXEL_MODE_GRAY:
break;
case FT_PIXEL_MODE_BGRA:
if (color) {
if (bitmap.buffer) {
/* convert non-8bpp bitmaps */
switch (bitmap.pixel_mode) {
case FT_PIXEL_MODE_MONO:
convert_scale = 255;
break;
}
/* we didn't ask for color, fall through to default */
default:
PyErr_SetString(PyExc_OSError, "unsupported bitmap pixel mode");
goto glyph_error;
}
/* clip glyph bitmap width to target image bounds */
x0 = 0;
x1 = bitmap.width;
if (xx < 0) {
x0 = -xx;
}
if (xx + x1 > im->xsize) {
x1 = im->xsize - xx;
}
source = (unsigned char *)bitmap.buffer;
for (bitmap_y = 0; bitmap_y < bitmap.rows; bitmap_y++, yy++) {
/* clip glyph bitmap height to target image bounds */
if (yy >= 0 && yy < im->ysize) {
/* blend this glyph into the buffer */
int k;
unsigned char *target;
unsigned int tmp;
if (color) {
/* target[RGB] returns the color, target[A] returns the mask */
/* target bands get split again in ImageDraw.text */
target = (unsigned char *)im->image[yy] + xx * 4;
} else {
target = im->image8[yy] + xx;
}
if (color && bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) {
/* paste color glyph */
for (k = x0; k < x1; k++) {
unsigned int src_alpha = source[k * 4 + 3];
/* paste only if source has data */
if (src_alpha > 0) {
/* unpremultiply BGRa */
int src_red =
CLIP8((255 * (int)source[k * 4 + 2]) / src_alpha);
int src_green =
CLIP8((255 * (int)source[k * 4 + 1]) / src_alpha);
int src_blue =
CLIP8((255 * (int)source[k * 4 + 0]) / src_alpha);
/* blend required if target has data */
if (target[k * 4 + 3] > 0) {
/* blend RGBA colors */
target[k * 4 + 0] =
BLEND(src_alpha, target[k * 4 + 0], src_red, tmp);
target[k * 4 + 1] =
BLEND(src_alpha, target[k * 4 + 1], src_green, tmp);
target[k * 4 + 2] =
BLEND(src_alpha, target[k * 4 + 2], src_blue, tmp);
target[k * 4 + 3] = CLIP8(
src_alpha +
MULDIV255(target[k * 4 + 3], (255 - src_alpha), tmp)
);
} else {
/* paste unpremultiplied RGBA values */
target[k * 4 + 0] = src_red;
target[k * 4 + 1] = src_green;
target[k * 4 + 2] = src_blue;
target[k * 4 + 3] = src_alpha;
}
}
case FT_PIXEL_MODE_GRAY2:
convert_scale = 255 / 3;
break;
case FT_PIXEL_MODE_GRAY4:
convert_scale = 255 / 15;
break;
default:
convert_scale = 1;
}
switch (bitmap.pixel_mode) {
case FT_PIXEL_MODE_MONO:
case FT_PIXEL_MODE_GRAY2:
case FT_PIXEL_MODE_GRAY4:
if (!bitmap_converted_ready) {
FT_Bitmap_Init(&bitmap_converted);
bitmap_converted_ready = 1;
}
} else if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) {
error = FT_Bitmap_Convert(library, &bitmap, &bitmap_converted, 1);
if (error) {
geterror(error);
goto glyph_error;
}
bitmap = bitmap_converted;
/* bitmap is now FT_PIXEL_MODE_GRAY, fall through */
case FT_PIXEL_MODE_GRAY:
break;
case FT_PIXEL_MODE_BGRA:
if (color) {
unsigned char *ink = (unsigned char *)&foreground_ink;
break;
}
/* we didn't ask for color, fall through to default */
default:
PyErr_SetString(PyExc_OSError, "unsupported bitmap pixel mode");
goto glyph_error;
}
/* clip glyph bitmap width to target image bounds */
x0 = 0;
x1 = bitmap.width;
if (xx < 0) {
x0 = -xx;
}
if (xx + x1 > im->xsize) {
x1 = im->xsize - xx;
}
source = (unsigned char *)bitmap.buffer;
for (bitmap_y = 0; bitmap_y < bitmap.rows; bitmap_y++, yy++) {
/* clip glyph bitmap height to target image bounds */
if (yy >= 0 && yy < im->ysize) {
/* blend this glyph into the buffer */
int k;
unsigned char *target;
unsigned int tmp;
if (color) {
/* target[RGB] returns the color, target[A] returns the mask */
/* target bands get split again in ImageDraw.text */
target = (unsigned char *)im->image[yy] + xx * 4;
} else {
target = im->image8[yy] + xx;
}
if (color && bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) {
/* paste color glyph */
for (k = x0; k < x1; k++) {
unsigned int src_alpha = source[k] * convert_scale;
unsigned int src_alpha = source[k * 4 + 3];
/* paste only if source has data */
if (src_alpha > 0) {
/* unpremultiply BGRa */
int src_red =
CLIP8((255 * (int)source[k * 4 + 2]) / src_alpha);
int src_green =
CLIP8((255 * (int)source[k * 4 + 1]) / src_alpha);
int src_blue =
CLIP8((255 * (int)source[k * 4 + 0]) / src_alpha);
/* blend required if target has data */
if (target[k * 4 + 3] > 0) {
/* blend RGBA colors */
target[k * 4 + 0] = BLEND(
src_alpha, target[k * 4 + 0], ink[0], tmp
src_alpha, target[k * 4 + 0], src_red, tmp
);
target[k * 4 + 1] = BLEND(
src_alpha, target[k * 4 + 1], ink[1], tmp
src_alpha, target[k * 4 + 1], src_green, tmp
);
target[k * 4 + 2] = BLEND(
src_alpha, target[k * 4 + 2], ink[2], tmp
src_alpha, target[k * 4 + 2], src_blue, tmp
);
target[k * 4 + 3] = CLIP8(
src_alpha +
@ -1174,35 +1143,68 @@ font_render(FontObject *self, PyObject *args) {
)
);
} else {
target[k * 4 + 0] = ink[0];
target[k * 4 + 1] = ink[1];
target[k * 4 + 2] = ink[2];
/* paste unpremultiplied RGBA values */
target[k * 4 + 0] = src_red;
target[k * 4 + 1] = src_green;
target[k * 4 + 2] = src_blue;
target[k * 4 + 3] = src_alpha;
}
}
}
} else {
for (k = x0; k < x1; k++) {
unsigned int src_alpha = source[k] * convert_scale;
if (src_alpha > 0) {
target[k] =
target[k] > 0
? CLIP8(
src_alpha +
MULDIV255(
target[k], (255 - src_alpha), tmp
} else if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) {
if (color) {
unsigned char *ink = (unsigned char *)&foreground_ink;
for (k = x0; k < x1; k++) {
unsigned int src_alpha = source[k] * convert_scale;
if (src_alpha > 0) {
if (target[k * 4 + 3] > 0) {
target[k * 4 + 0] = BLEND(
src_alpha, target[k * 4 + 0], ink[0], tmp
);
target[k * 4 + 1] = BLEND(
src_alpha, target[k * 4 + 1], ink[1], tmp
);
target[k * 4 + 2] = BLEND(
src_alpha, target[k * 4 + 2], ink[2], tmp
);
target[k * 4 + 3] = CLIP8(
src_alpha + MULDIV255(
target[k * 4 + 3],
(255 - src_alpha),
tmp
)
);
} else {
target[k * 4 + 0] = ink[0];
target[k * 4 + 1] = ink[1];
target[k * 4 + 2] = ink[2];
target[k * 4 + 3] = src_alpha;
}
}
}
} else {
for (k = x0; k < x1; k++) {
unsigned int src_alpha = source[k] * convert_scale;
if (src_alpha > 0) {
target[k] =
target[k] > 0
? CLIP8(
src_alpha +
MULDIV255(
target[k], (255 - src_alpha), tmp
)
)
)
: src_alpha;
: src_alpha;
}
}
}
} else {
PyErr_SetString(PyExc_OSError, "unsupported bitmap pixel mode");
goto glyph_error;
}
} else {
PyErr_SetString(PyExc_OSError, "unsupported bitmap pixel mode");
goto glyph_error;
}
source += bitmap.pitch;
}
source += bitmap.pitch;
}
x += glyph_info[i].x_advance;
y += glyph_info[i].y_advance;

View File

@ -163,7 +163,7 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) {
x0 = y0 = x1 = y1 = 0;
/* FIXME: should publish the ImagingType descriptor */
if (!PyArg_ParseTuple(args, "O|(iiii)", &op, &x0, &y0, &x1, &y1)) {
if (!PyArg_ParseTuple(args, "O(iiii)", &op, &x0, &y0, &x1, &y1)) {
return NULL;
}
im = PyImaging_AsImaging(op);
@ -176,15 +176,10 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) {
state = &decoder->state;
/* Setup decoding tile extent */
if (x0 == 0 && x1 == 0) {
state->xsize = im->xsize;
state->ysize = im->ysize;
} else {
state->xoff = x0;
state->yoff = y0;
state->xsize = x1 - x0;
state->ysize = y1 - y0;
}
state->xoff = x0;
state->yoff = y0;
state->xsize = x1 - x0;
state->ysize = y1 - y0;
if (state->xoff < 0 || state->xsize <= 0 ||
state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 ||

View File

@ -232,7 +232,7 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) {
x0 = y0 = x1 = y1 = 0;
/* FIXME: should publish the ImagingType descriptor */
if (!PyArg_ParseTuple(args, "O|(nnnn)", &op, &x0, &y0, &x1, &y1)) {
if (!PyArg_ParseTuple(args, "O(nnnn)", &op, &x0, &y0, &x1, &y1)) {
return NULL;
}
im = PyImaging_AsImaging(op);
@ -248,15 +248,10 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) {
state = &encoder->state;
if (x0 == 0 && x1 == 0) {
state->xsize = im->xsize;
state->ysize = im->ysize;
} else {
state->xoff = x0;
state->yoff = y0;
state->xsize = x1 - x0;
state->ysize = y1 - y0;
}
state->xoff = x0;
state->yoff = y0;
state->xsize = x1 - x0;
state->ysize = y1 - y0;
if (state->xoff < 0 || state->xsize <= 0 ||
state->xsize + state->xoff > im->xsize || state->yoff < 0 ||

View File

@ -601,6 +601,7 @@ j2ku_sycca_rgba(
static const struct j2k_decode_unpacker j2k_unpackers[] = {
{IMAGING_MODE_L, OPJ_CLRSPC_GRAY, 1, 0, j2ku_gray_l},
{IMAGING_MODE_P, OPJ_CLRSPC_SRGB, 1, 0, j2ku_gray_l},
{IMAGING_MODE_P, OPJ_CLRSPC_CMYK, 1, 0, j2ku_gray_l},
{IMAGING_MODE_PA, OPJ_CLRSPC_SRGB, 2, 0, j2ku_graya_la},
{IMAGING_MODE_I_16, OPJ_CLRSPC_GRAY, 1, 0, j2ku_gray_i},
{IMAGING_MODE_I_16B, OPJ_CLRSPC_GRAY, 1, 0, j2ku_gray_i},

View File

@ -325,6 +325,19 @@ ImagingPackXBGR(UINT8 *out, const UINT8 *in, int pixels) {
}
}
void
ImagingPackCMYK2RGB(UINT8 *out, const UINT8 *in, int xsize) {
int x, nk, tmp;
for (x = 0; x < xsize; x++) {
nk = 255 - in[3];
out[0] = CLIP8(nk - MULDIV255(in[0], nk, tmp));
out[1] = CLIP8(nk - MULDIV255(in[1], nk, tmp));
out[2] = CLIP8(nk - MULDIV255(in[2], nk, tmp));
out += 3;
in += 4;
}
}
void
ImagingPackBGRA(UINT8 *out, const UINT8 *in, int pixels) {
int i;
@ -605,6 +618,7 @@ static struct {
{IMAGING_MODE_CMYK, IMAGING_RAWMODE_M, 8, band1},
{IMAGING_MODE_CMYK, IMAGING_RAWMODE_Y, 8, band2},
{IMAGING_MODE_CMYK, IMAGING_RAWMODE_K, 8, band3},
{IMAGING_MODE_CMYK, IMAGING_RAWMODE_RGB, 24, ImagingPackCMYK2RGB},
/* video (YCbCr) */
{IMAGING_MODE_YCbCr, IMAGING_RAWMODE_YCbCr, 24, ImagingPackRGB},

View File

@ -27,7 +27,8 @@ ImagingPaletteNew(const ModeID mode) {
int i;
ImagingPalette palette;
if (mode != IMAGING_MODE_RGB && mode != IMAGING_MODE_RGBA) {
if (mode != IMAGING_MODE_RGB && mode != IMAGING_MODE_RGBA &&
mode != IMAGING_MODE_CMYK) {
return (ImagingPalette)ImagingError_ModeError();
}