From 63c9886ed2795bf51f11f72bf28c3f4eb573e35a Mon Sep 17 00:00:00 2001
From: Benjamin Gilbert <bgilbert@backtick.net>
Date: Tue, 24 Oct 2023 23:55:19 -0500
Subject: [PATCH] Allow writing RGB JPEGs

libjpeg automatically converts RGB to YCbCr by default.  Add a
keep_colorspace option to disable libjpeg's automatic colorspace conversion
during write.
---
 Tests/test_file_jpeg.py              | 14 ++++++++++++++
 docs/handbook/image-file-formats.rst |  7 +++++++
 src/PIL/JpegImagePlugin.py           |  1 +
 src/encode.c                         |  5 ++++-
 src/libImaging/Jpeg.h                |  3 +++
 src/libImaging/JpegEncode.c          | 11 +++++++++++
 6 files changed, 40 insertions(+), 1 deletion(-)

diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index ef070b6c5ba..df32a88e1b3 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -141,6 +141,16 @@ def test_cmyk(self):
             )
             assert k > 0.9
 
+    def test_rgb(self):
+        def getchannels(im):
+            return tuple(v[0] for v in im.layer)
+
+        im = self.roundtrip(hopper())
+        assert getchannels(im) == (1, 2, 3)
+        im = self.roundtrip(hopper(), keep_colorspace=True)
+        assert getchannels(im) == (ord("R"), ord("G"), ord("B"))
+        assert_image_similar(hopper(), im, 12)
+
     @pytest.mark.parametrize(
         "test_image_path",
         [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"],
@@ -445,6 +455,10 @@ def getsampling(im):
         with pytest.raises(TypeError):
             self.roundtrip(hopper(), subsampling="1:1:1")
 
+        # RGB colorspace, no subsampling by default
+        im = self.roundtrip(hopper(), subsampling=3, keep_colorspace=True)
+        assert getsampling(im) == (1, 1, 1, 1, 1, 1)
+
     def test_exif(self):
         with Image.open("Tests/images/pil_sample_rgb.jpg") as im:
             info = im._getexif()
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index fe310df6443..87275fcdc7c 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -483,6 +483,13 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
 **exif**
     If present, the image will be stored with the provided raw EXIF data.
 
+**keep_colorspace**
+    If present and true, indicates that the encoder should retain the
+    image's original color space, rather than automatically converting RGB to
+    YCbCr.
+
+    .. versionadded:: 10.2.0
+
 **subsampling**
     If present, sets the subsampling for the encoder.
 
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index 3596e089949..26c9f2da5c7 100644
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -783,6 +783,7 @@ def validate_qtables(qtables):
         progressive,
         info.get("smooth", 0),
         optimize,
+        info.get("keep_colorspace", False),
         info.get("streamtype", 0),
         dpi[0],
         dpi[1],
diff --git a/src/encode.c b/src/encode.c
index 4664ad0f32a..596e62bcb51 100644
--- a/src/encode.c
+++ b/src/encode.c
@@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
     Py_ssize_t progressive = 0;
     Py_ssize_t smooth = 0;
     Py_ssize_t optimize = 0;
+    int keep_colorspace = 0;
     Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */
     Py_ssize_t xdpi = 0, ydpi = 0;
     Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */
@@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
 
     if (!PyArg_ParseTuple(
             args,
-            "ss|nnnnnnnnnnOz#y#y#",
+            "ss|nnnnpnnnnnnOz#y#y#",
             &mode,
             &rawmode,
             &quality,
             &progressive,
             &smooth,
             &optimize,
+            &keep_colorspace,
             &streamtype,
             &xdpi,
             &ydpi,
@@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
 
     strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8);
 
+    ((JPEGENCODERSTATE *)encoder->state.context)->keep_colorspace = keep_colorspace;
     ((JPEGENCODERSTATE *)encoder->state.context)->quality = quality;
     ((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays;
     ((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen;
diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h
index 5cc74e69bf5..518e04880e7 100644
--- a/src/libImaging/Jpeg.h
+++ b/src/libImaging/Jpeg.h
@@ -74,6 +74,9 @@ typedef struct {
     /* Optimize Huffman tables (slow) */
     int optimize;
 
+    /* Disable automatic colorspace conversion if nonzero */
+    int keep_colorspace;
+
     /* Stream type (0=full, 1=tables only, 2=image only) */
     int streamtype;
 
diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c
index 9da830b186f..a0b9363852c 100644
--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -137,6 +137,17 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
             /* Compressor configuration */
             jpeg_set_defaults(&context->cinfo);
 
+            /* Prevent RGB -> YCbCr conversion */
+            if (context->keep_colorspace) {
+                J_COLOR_SPACE space = context->cinfo.in_color_space;
+#ifdef JCS_EXTENSIONS
+                if (context->cinfo.in_color_space == JCS_EXT_RGBX) {
+                    space = JCS_RGB;
+                }
+#endif
+                jpeg_set_colorspace(&context->cinfo, space);
+            }
+
             /* Use custom quantization tables */
             if (context->qtables) {
                 int i;