Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support saving JPEG comments #6774

Merged
merged 12 commits into from Dec 7, 2022
27 changes: 27 additions & 0 deletions Tests/test_file_jpeg.py
Expand Up @@ -86,6 +86,33 @@ def test_app(self):
assert len(im.applist) == 2

assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0\x00"
assert im.app["COM"] == im.info["comment"]

def test_comment_write(self):
with Image.open(TEST_FILE) as im:
assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0\x00"

# Test that existing comment is saved by default
out = BytesIO()
im.save(out, format="JPEG")
with Image.open(out) as reloaded:
assert im.info["comment"] == reloaded.info["comment"]

# Ensure that a blank comment causes any existing comment to be removed
for comment in ("", b"", None):
out = BytesIO()
im.save(out, format="JPEG", comment=comment)
with Image.open(out) as reloaded:
assert "comment" not in reloaded.info

# Test that a comment argument overrides the default comment
for comment in ("Test comment text", b"Text comment text"):
out = BytesIO()
im.save(out, format="JPEG", comment=comment)
with Image.open(out) as reloaded:
if not isinstance(comment, bytes):
comment = comment.encode()
assert reloaded.info["comment"] == comment

def test_cmyk(self):
# Test CMYK handling. Thanks to Tim and Charlie for test data,
Expand Down
6 changes: 5 additions & 1 deletion src/PIL/JpegImagePlugin.py
Expand Up @@ -45,6 +45,7 @@
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._binary import o8
from ._binary import o16be as o16
from ._deprecate import deprecate
from .JpegPresets import presets

Expand Down Expand Up @@ -724,7 +725,7 @@ def validate_qtables(qtables):
icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:]
i = 1
for marker in markers:
size = struct.pack(">H", 2 + ICC_OVERHEAD_LEN + len(marker))
size = o16(2 + ICC_OVERHEAD_LEN + len(marker))
extra += (
b"\xFF\xE2"
+ size
Expand All @@ -735,6 +736,8 @@ def validate_qtables(qtables):
)
i += 1

comment = info.get("comment", im.info.get("comment"))

# "progressive" is the official name, but older documentation
# says "progression"
# FIXME: issue a warning if the wrong form is used (post-1.1.7)
Expand All @@ -757,6 +760,7 @@ def validate_qtables(qtables):
dpi[1],
subsampling,
qtables,
comment,
extra,
exif,
)
Expand Down
26 changes: 22 additions & 4 deletions src/encode.c
Expand Up @@ -1048,14 +1048,16 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
PyObject *qtables = NULL;
unsigned int *qarrays = NULL;
int qtablesLen = 0;
char *comment = NULL;
Py_ssize_t comment_size;
char *extra = NULL;
Py_ssize_t extra_size;
char *rawExif = NULL;
Py_ssize_t rawExifLen = 0;

if (!PyArg_ParseTuple(
args,
"ss|nnnnnnnnOy#y#",
"ss|nnnnnnnnOz#y#y#",
smason marked this conversation as resolved.
Show resolved Hide resolved
&mode,
&rawmode,
&quality,
Expand All @@ -1067,6 +1069,8 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
&ydpi,
&subsampling,
&qtables,
&comment,
&comment_size,
&extra,
&extra_size,
&rawExif,
Expand All @@ -1090,12 +1094,24 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
return NULL;
}

// Freed in JpegEncode, Case 5
// Freed in JpegEncode, Case 6
qarrays = get_qtables_arrays(qtables, &qtablesLen);

if (comment && comment_size > 0) {
/* malloc check ok, length is from python parsearg */
char *p = malloc(comment_size); // Freed in JpegEncode, Case 6
if (!p) {
return ImagingError_MemoryError();
}
memcpy(p, comment, comment_size);
comment = p;
} else {
comment = NULL;
}

if (extra && extra_size > 0) {
/* malloc check ok, length is from python parsearg */
char *p = malloc(extra_size); // Freed in JpegEncode, Case 5
char *p = malloc(extra_size); // Freed in JpegEncode, Case 6
if (!p) {
return ImagingError_MemoryError();
}
Expand All @@ -1107,7 +1123,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {

if (rawExif && rawExifLen > 0) {
/* malloc check ok, length is from python parsearg */
char *pp = malloc(rawExifLen); // Freed in JpegEncode, Case 5
char *pp = malloc(rawExifLen); // Freed in JpegEncode, Case 6
if (!pp) {
if (extra) {
free(extra);
Expand All @@ -1134,6 +1150,8 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
((JPEGENCODERSTATE *)encoder->state.context)->streamtype = streamtype;
((JPEGENCODERSTATE *)encoder->state.context)->xdpi = xdpi;
((JPEGENCODERSTATE *)encoder->state.context)->ydpi = ydpi;
((JPEGENCODERSTATE *)encoder->state.context)->comment = comment;
((JPEGENCODERSTATE *)encoder->state.context)->comment_size = comment_size;
((JPEGENCODERSTATE *)encoder->state.context)->extra = extra;
((JPEGENCODERSTATE *)encoder->state.context)->extra_size = extra_size;
((JPEGENCODERSTATE *)encoder->state.context)->rawExif = rawExif;
Expand Down
4 changes: 4 additions & 0 deletions src/libImaging/Jpeg.h
Expand Up @@ -92,6 +92,10 @@ typedef struct {
/* in factors of DCTSIZE2 */
int qtablesLen;

/* Comment */
char *comment;
size_t comment_size;

/* Extra data (to be injected after header) */
char *extra;
int extra_size;
Expand Down
13 changes: 12 additions & 1 deletion src/libImaging/JpegEncode.c
Expand Up @@ -277,6 +277,13 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
}

case 4:

if (context->comment) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the rest of the code is conditional on comment != NULL rather than comment_size > 0

they should be the same, but keeping things consistent feels nicer to me

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you say "the rest of the code", what are you referring to?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, that wasn't explained well

PyImaging_JpegEncoderNew seems to imply that comment_size is only valid when comment is non-NULL. e.g. comment is initialised to NULL, but comment_size isn't initialised to zero, and later in function (line 1109) it only sets comment = NULL

jpeg_write_marker(&context->cinfo, JPEG_COM, (unsigned char *)context->comment, context->comment_size);
}
state->state++;

case 5:
if (1024 > context->destination.pub.free_in_buffer) {
break;
}
Expand All @@ -301,7 +308,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
state->state++;
/* fall through */

case 5:
case 6:

/* Finish compression */
if (context->destination.pub.free_in_buffer < 100) {
Expand All @@ -310,6 +317,10 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
jpeg_finish_compress(&context->cinfo);

/* Clean up */
if (context->comment) {
free(context->comment);
context->comment = NULL;
}
if (context->extra) {
free(context->extra);
context->extra = NULL;
Expand Down