Repository: python-pillow/Pillow

Issue:
Copying a JPEG quantization table as a list breaks the value order
### What did you do?

```python
from PIL import Image

im = Image.open('any_existing.jpg')
im.save('output.jpg', qtables=[im.quantization[0]])
im2 = Image.open('output.jpg')
assert im2.quantization[0] == im.quantization[0]
```

### What did you expect to happen?

The assert passes.

### What actually happened?

It fails.

If you replace the second line by one of the following, it does pass:

```python
im.save('output.jpg', qtables=im.quantization)
im.save('output.jpg', qtables={0: im.quantization[0]})
```

The reason is that JPEGs store their quantization table in zigzag order. When reading JPEGs, [Pillow loads the table into the `quantization` property unchanged](https://github.com/python-pillow/Pillow/blob/804c76ff126496011797cd93cc05fe4461023cac/src/PIL/JpegImagePlugin.py#L235) which means the values are in zigzag order. 

But when _writing_, Pillow passes the `qtables` value to libjpeg's `jpeg_add_quant_table`, which takes values in normal order (since libjpeg version 6a).

Pillow has the built-in [`convert_dict_qtables`](https://github.com/python-pillow/Pillow/blob/804c76ff126496011797cd93cc05fe4461023cac/src/PIL/JpegImagePlugin.py#L586) to convert this dict of zigzagged arrays stored in `quantization` back into normal-order arrays. It uses that internally when _saving_ using a dict value, so if you use `qtables='keep'`, or `qtables=im.quantization` things work as expected.

But if you write the quantization data from an existing JPEG as a list, `jpeg_add_quant_table` ends up re-zigzagging the already zigzagged data.

I think it would be less confusing if Pillow already de-zigzagged the values when it reads them in the `DQT` function.

Note also that the existing `test_qtables` test is already affected by this:

https://github.com/python-pillow/Pillow/blob/804c76ff126496011797cd93cc05fe4461023cac/Tests/test_file_jpeg.py#L460

But the only thing that line really does is check that `save` doesn't raise any exceptions. It does not check that the input and output images are the same.


Discussion:

Resolving patch:
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -254,7 +254,7 @@ def DQT(self, marker):
         data = array.array("B" if precision == 1 else "H", s[1:qt_length])
         if sys.byteorder == "little" and precision > 1:
             data.byteswap()  # the values are always big-endian
-        self.quantization[v & 15] = data
+        self.quantization[v & 15] = [data[i] for i in zigzag_index]
         s = s[qt_length:]
 
 
@@ -601,9 +601,11 @@ def _getmp(self):
 
 
 def convert_dict_qtables(qtables):
-    qtables = [qtables[key] for key in range(len(qtables)) if key in qtables]
-    for idx, table in enumerate(qtables):
-        qtables[idx] = [table[i] for i in zigzag_index]
+    warnings.warn(
+        "convert_dict_qtables is deprecated and will be removed in Pillow 10"
+        "(2023-01-02). Conversion is no longer needed.",
+        DeprecationWarning,
+    )
     return qtables
 
 
@@ -684,7 +686,9 @@ def validate_qtables(qtables):
                 qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)]
         if isinstance(qtables, (tuple, list, dict)):
             if isinstance(qtables, dict):
-                qtables = convert_dict_qtables(qtables)
+                qtables = [
+                    qtables[key] for key in range(len(qtables)) if key in qtables
+                ]
             elif isinstance(qtables, tuple):
                 qtables = list(qtables)
             if not (0 < len(qtables) < 5):
diff --git a/src/PIL/JpegPresets.py b/src/PIL/JpegPresets.py
--- a/src/PIL/JpegPresets.py
+++ b/src/PIL/JpegPresets.py
@@ -52,19 +52,11 @@
 
   im.quantization
 
-This will return a dict with a number of arrays. You can pass this dict
+This will return a dict with a number of lists. You can pass this dict
 directly as the qtables argument when saving a JPEG.
 
-The tables format between im.quantization and quantization in presets differ in
-3 ways:
-
-1. The base container of the preset is a list with sublists instead of dict.
-   dict[0] -> list[0], dict[1] -> list[1], ...
-2. Each table in a preset is a list instead of an array.
-3. The zigzag order is remove in the preset (needed by libjpeg >= 6a).
-
-You can convert the dict format to the preset format with the
-:func:`.JpegImagePlugin.convert_dict_qtables()` function.
+The quantization table format in presets is a list with sublists. These formats
+are interchangeable.
 
 Libjpeg ref.:
 https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html


Test patch:
diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -466,7 +466,7 @@ def _n_qtables_helper(n, test_file):
                 assert len(im.quantization) == n
                 reloaded = self.roundtrip(im, qtables="keep")
                 assert im.quantization == reloaded.quantization
-                assert reloaded.quantization[0].typecode == "B"
+                assert max(reloaded.quantization[0]) <= 255
 
         with Image.open("Tests/images/hopper.jpg") as im:
             qtables = im.quantization
@@ -478,7 +478,8 @@ def _n_qtables_helper(n, test_file):
 
             # valid bounds for baseline qtable
             bounds_qtable = [int(s) for s in ("255 1 " * 32).split(None)]
-            self.roundtrip(im, qtables=[bounds_qtable])
+            im2 = self.roundtrip(im, qtables=[bounds_qtable])
+            assert im2.quantization == {0: bounds_qtable}
 
             # values from wizard.txt in jpeg9-a src package.
             standard_l_qtable = [
@@ -589,6 +590,12 @@ def test_save_low_quality_baseline_qtables(self):
             assert max(im2.quantization[0]) <= 255
             assert max(im2.quantization[1]) <= 255
 
+    def test_convert_dict_qtables_deprecation(self):
+        with pytest.warns(DeprecationWarning):
+            qtable = {0: [1, 2, 3, 4]}
+            qtable2 = JpegImagePlugin.convert_dict_qtables(qtable)
+            assert qtable == qtable2
+
     @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available")
     def test_load_djpeg(self):
         with Image.open(TEST_FILE) as img: