diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 81795d54c..b6e8215f7 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -4,13 +4,21 @@ from io import BytesIO import pytest -from PIL import Image, ImageFile, Jpeg2KImagePlugin, UnidentifiedImageError, features +from PIL import ( + Image, + ImageFile, + Jpeg2KImagePlugin, + UnidentifiedImageError, + _binary, + features, +) from .helper import ( assert_image_equal, assert_image_similar, assert_image_similar_tofile, skip_unless_feature, + skip_unless_feature_version, ) EXTRA_DIR = "Tests/images/jpeg2000" @@ -364,6 +372,24 @@ def test_comment(): pass +def test_save_comment(): + for comment in ("Created by Pillow", b"Created by Pillow"): + out = BytesIO() + test_card.save(out, "JPEG2000", comment=comment) + + with Image.open(out) as im: + assert im.info["comment"] == b"Created by Pillow" + + out = BytesIO() + long_comment = b" " * 65531 + test_card.save(out, "JPEG2000", comment=long_comment) + with Image.open(out) as im: + assert im.info["comment"] == long_comment + + with pytest.raises(ValueError): + test_card.save(out, "JPEG2000", comment=long_comment + b" ") + + @pytest.mark.parametrize( "test_file", [ @@ -381,3 +407,29 @@ def test_crashes(test_file): im.load() except OSError: pass + + +@skip_unless_feature_version("jpg_2000", "2.4.0") +def test_plt_marker(): + # Search the start of the codesteam for PLT + out = BytesIO() + test_card.save(out, "JPEG2000", no_jp2=True, plt=True) + out.seek(0) + while True: + marker = out.read(2) + if not marker: + assert False, "End of stream without PLT" + + jp2_boxid = _binary.i16be(marker) + if jp2_boxid == 0xFF4F: + # SOC has no length + continue + elif jp2_boxid == 0xFF58: + # PLT + return + elif jp2_boxid == 0xFF93: + assert False, "SOD without finding PLT first" + + hdr = out.read(2) + length = _binary.i16be(hdr) + out.seek(length - 2, os.SEEK_CUR) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a5f946feb..74ba883b1 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -589,6 +589,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: .. versionadded:: 9.1.0 +**comment** + Adds a custom comment to the file, replacing the default + "Created by OpenJPEG version" comment. + + .. versionadded:: 9.5.0 + +**plt** + If ``True`` and OpenJPEG 2.4.0 or later is available, then include a PLT + (packet length, tile-part header) marker in the produced file. + Defaults to ``False``. + + .. versionadded:: 9.5.0 + .. note:: To enable JPEG 2000 support, you need to build and install the OpenJPEG diff --git a/docs/releasenotes/9.5.0.rst b/docs/releasenotes/9.5.0.rst index a6234efea..0b0e0dd2f 100644 --- a/docs/releasenotes/9.5.0.rst +++ b/docs/releasenotes/9.5.0.rst @@ -48,11 +48,16 @@ Added ``corners`` argument to ``ImageDraw.rounded_rectangle()`` ``corners``. This a tuple of Booleans, specifying whether to round each corner, ``(top_left, top_right, bottom_right, bottom_left)``. -Reading JPEG comments -^^^^^^^^^^^^^^^^^^^^^ +JPEG2000 comments and PLT marker +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When opening a JPEG2000 image, the comment may now be read into -:py:attr:`~PIL.Image.Image.info`. +:py:attr:`~PIL.Image.Image.info`. The ``comment`` keyword argument can be used +to save it back again. + +If OpenJPEG 2.4.0 or later is available and the ``plt`` keyword argument +is present and true when saving JPEG2000 images, tell the encoder to generate +PLT markers. Security ======== diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 1dec2f84a..9309768ba 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -17,7 +17,7 @@ import io import os import struct -from . import Image, ImageFile +from . import Image, ImageFile, _binary class BoxReader: @@ -99,7 +99,7 @@ def _parse_codestream(fp): count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" hdr = fp.read(2) - lsiz = struct.unpack(">H", hdr)[0] + lsiz = _binary.i16be(hdr) siz = hdr + fp.read(lsiz - 2) lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from( ">HHIIIIIIIIH", siz @@ -258,7 +258,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def _parse_comment(self): hdr = self.fp.read(2) - length = struct.unpack(">H", hdr)[0] + length = _binary.i16be(hdr) self.fp.seek(length - 2, os.SEEK_CUR) while True: @@ -270,7 +270,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): # Start of tile or end of codestream break hdr = self.fp.read(2) - length = struct.unpack(">H", hdr)[0] + length = _binary.i16be(hdr) if typ == 0x64: # Comment self.info["comment"] = self.fp.read(length - 2)[2:] @@ -351,8 +351,12 @@ def _save(im, fp, filename): cinema_mode = info.get("cinema_mode", "no") mct = info.get("mct", 0) signed = info.get("signed", False) - fd = -1 + comment = info.get("comment") + if isinstance(comment, str): + comment = comment.encode() + plt = info.get("plt", False) + fd = -1 if hasattr(fp, "fileno"): try: fd = fp.fileno() @@ -374,6 +378,8 @@ def _save(im, fp, filename): mct, signed, fd, + comment, + plt, ) ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) diff --git a/src/encode.c b/src/encode.c index 21c42d915..a66594935 100644 --- a/src/encode.c +++ b/src/encode.c @@ -1214,10 +1214,13 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { char mct = 0; int sgnd = 0; Py_ssize_t fd = -1; + char *comment; + Py_ssize_t comment_size; + int plt = 0; if (!PyArg_ParseTuple( args, - "ss|OOOsOnOOOssbbn", + "ss|OOOsOnOOOssbbnz#p", &mode, &format, &offset, @@ -1233,7 +1236,10 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { &cinema_mode, &mct, &sgnd, - &fd)) { + &fd, + &comment, + &comment_size, + &plt)) { return NULL; } @@ -1315,6 +1321,26 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { } } + if (comment && comment_size > 0) { + /* Size is stored as as an uint16, subtract 4 bytes for the header */ + if (comment_size >= 65532) { + PyErr_SetString( + PyExc_ValueError, + "JPEG 2000 comment is too long"); + Py_DECREF(encoder); + return NULL; + } + + char *p = malloc(comment_size + 1); + if (!p) { + Py_DECREF(encoder); + return ImagingError_MemoryError(); + } + memcpy(p, comment, comment_size); + p[comment_size] = '\0'; + context->comment = p; + } + if (quality_layers && PySequence_Check(quality_layers)) { context->quality_is_in_db = strcmp(quality_mode, "dB") == 0; context->quality_layers = quality_layers; @@ -1332,6 +1358,7 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { context->cinema_mode = cine_mode; context->mct = mct; context->sgnd = sgnd; + context->plt = plt; return (PyObject *)encoder; } diff --git a/src/libImaging/Jpeg2K.h b/src/libImaging/Jpeg2K.h index b28a0440a..e8d92f7b6 100644 --- a/src/libImaging/Jpeg2K.h +++ b/src/libImaging/Jpeg2K.h @@ -97,6 +97,12 @@ typedef struct { /* PRIVATE CONTEXT (set by decoder) */ const char *error_msg; + /* Custom comment */ + char *comment; + + /* Include PLT marker segment */ + int plt; + } JPEG2KENCODESTATE; /* diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index 62d22bcc6..8f6370061 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -439,6 +439,10 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { params.tcp_mct = context->mct; } + if (context->comment) { + params.cp_comment = context->comment; + } + params.prog_order = context->progression; params.cp_cinema = context->cinema_mode; @@ -496,6 +500,14 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { opj_set_warning_handler(codec, j2k_warn, context); opj_setup_encoder(codec, ¶ms, image); + /* Enabling PLT markers only supported in OpenJPEG 2.4.0 and up */ +#if ((OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR >= 4) || OPJ_VERSION_MAJOR > 2) + if (context->plt) { + const char *plt_option[2] = {"PLT=YES", NULL}; + opj_encoder_set_extra_options(codec, plt_option); + } +#endif + /* Start encoding */ if (!opj_start_compress(codec, image, stream)) { state->errcode = IMAGING_CODEC_BROKEN; @@ -628,7 +640,12 @@ ImagingJpeg2KEncodeCleanup(ImagingCodecState state) { free((void *)context->error_msg); } + if (context->comment) { + free((void *)context->comment); + } + context->error_msg = NULL; + context->comment = NULL; return -1; }