Repository: python-pillow/Pillow

Issue:
10 bit pgm file not read correctly
### What did you do?
Load 10 bit pgm image and saved it as png image

### What did you expect to happen?
Pixel values scaled to use the 16 bit range 
(http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html)

### What actually happened?
pixel values stored in the least significant bytes and hence the image is too dark

### What are your OS, Python and Pillow versions?

* OS:  Ubuntu 18.04.5 LTS
* Python: 3.6
* Pillow: 8.2.0

```python
from PIL import Image

input_path_pgm = 'path-to-10-bit-pgm-image'
output_path_png = 'path-to-16-bit-png-image'
image = Image.open(input_path_pgm)
image.save(output_path_png, format='PNG')
```


Discussion:
Could you provide a copy of the input image?
Yeah sure! Here is an example of a 10 bit pgm image.
[cat.zip](https://github.com/python-pillow/Pillow/files/6308903/cat.zip)

Just for the record, the rawmode of this image is detected as I;16B
Pillow does not auto-scale image values, even when converting between 8 bit and 16 bit formats.

This is a longstanding issue with expectations, but changing it at this point would be a breaking change. 
Considering that this unexpected behavior is undocumented (correct me if I am wrong) and violates the PNG specification, such a breaking change would maybe be acceptable?
I've created PR #6107 to resolve this.

Just to be clear - there is no problem with how the PNG is saved in the original post here. The problem is just in how a 10-bit PGM file is read by Pillow.
Why stop at 10-bit? There are 12-bit pixel formats as well, e.g. used in very high quality HDR production. [FFmpeg](http://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavutil/pixfmt.h;hb=HEAD) also defines 14-bit, and even 9-bit... 12 and 14 are also common bit depths for camera sensors, and raw data is sometimes dumped/converted to PGM(-like) files.
Ok, I've added 9, 12 and 14 to the PR as well.

Resolving patch:
diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py
--- a/src/PIL/PpmImagePlugin.py
+++ b/src/PIL/PpmImagePlugin.py
@@ -16,6 +16,9 @@
 
 
 from . import Image, ImageFile
+from ._binary import i16be as i16
+from ._binary import o8
+from ._binary import o32le as o32
 
 #
 # --------------------------------------------------------------------
@@ -102,6 +105,7 @@ def _open(self):
         else:
             self.mode = rawmode = mode
 
+        decoder_name = "raw"
         for ix in range(3):
             token = int(self._read_token())
             if ix == 0:  # token is the x size
@@ -112,18 +116,44 @@ def _open(self):
                     break
             elif ix == 2:  # token is maxval
                 maxval = token
-                if maxval > 255:
-                    if not mode == "L":
-                        raise ValueError(f"Too many colors for band: {token}")
-                    if maxval < 2**16:
-                        self.mode = "I"
-                        rawmode = "I;16B"
-                    else:
-                        self.mode = "I"
-                        rawmode = "I;32B"
+                if maxval > 255 and mode == "L":
+                    self.mode = "I"
+
+                # If maxval matches a bit depth, use the raw decoder directly
+                if maxval == 65535 and mode == "L":
+                    rawmode = "I;16B"
+                elif maxval != 255:
+                    decoder_name = "ppm"
+        args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval)
 
         self._size = xsize, ysize
-        self.tile = [("raw", (0, 0, xsize, ysize), self.fp.tell(), (rawmode, 0, 1))]
+        self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)]
+
+
+class PpmDecoder(ImageFile.PyDecoder):
+    _pulls_fd = True
+
+    def decode(self, buffer):
+        data = bytearray()
+        maxval = min(self.args[-1], 65535)
+        in_byte_count = 1 if maxval < 256 else 2
+        out_byte_count = 4 if self.mode == "I" else 1
+        out_max = 65535 if self.mode == "I" else 255
+        bands = Image.getmodebands(self.mode)
+        while len(data) < self.state.xsize * self.state.ysize * bands * out_byte_count:
+            pixels = self.fd.read(in_byte_count * bands)
+            if len(pixels) < in_byte_count * bands:
+                # eof
+                break
+            for b in range(bands):
+                value = (
+                    pixels[b] if in_byte_count == 1 else i16(pixels, b * in_byte_count)
+                )
+                value = min(out_max, round(value / maxval * out_max))
+                data += o32(value) if self.mode == "I" else o8(value)
+        rawmode = "I;32" if self.mode == "I" else self.mode
+        self.set_as_raw(bytes(data), (rawmode, 0, 1))
+        return -1, 0
 
 
 #
@@ -136,26 +166,19 @@ def _save(im, fp, filename):
     elif im.mode == "L":
         rawmode, head = "L", b"P5"
     elif im.mode == "I":
-        if im.getextrema()[1] < 2**16:
-            rawmode, head = "I;16B", b"P5"
-        else:
-            rawmode, head = "I;32B", b"P5"
-    elif im.mode == "RGB":
-        rawmode, head = "RGB", b"P6"
-    elif im.mode == "RGBA":
+        rawmode, head = "I;16B", b"P5"
+    elif im.mode in ("RGB", "RGBA"):
         rawmode, head = "RGB", b"P6"
     else:
         raise OSError(f"cannot write mode {im.mode} as PPM")
-    fp.write(head + ("\n%d %d\n" % im.size).encode("ascii"))
+    fp.write(head + b"\n%d %d\n" % im.size)
     if head == b"P6":
         fp.write(b"255\n")
-    if head == b"P5":
+    elif head == b"P5":
         if rawmode == "L":
             fp.write(b"255\n")
-        elif rawmode == "I;16B":
+        else:
             fp.write(b"65535\n")
-        elif rawmode == "I;32B":
-            fp.write(b"2147483648\n")
     ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))])
 
     # ALTERNATIVE: save via builtin debug function
@@ -169,6 +192,8 @@ def _save(im, fp, filename):
 Image.register_open(PpmImageFile.format, PpmImageFile, _accept)
 Image.register_save(PpmImageFile.format, _save)
 
+Image.register_decoder("ppm", PpmDecoder)
+
 Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"])
 
 Image.register_mime(PpmImageFile.format, "image/x-portable-anymap")


Test patch:
diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py
--- a/Tests/test_file_ppm.py
+++ b/Tests/test_file_ppm.py
@@ -13,16 +13,53 @@
 
 def test_sanity():
     with Image.open(TEST_FILE) as im:
-        im.load()
         assert im.mode == "RGB"
         assert im.size == (128, 128)
-        assert im.format, "PPM"
+        assert im.format == "PPM"
         assert im.get_format_mimetype() == "image/x-portable-pixmap"
 
 
+@pytest.mark.parametrize(
+    "data, mode, pixels",
+    (
+        (b"P5 3 1 4 \x00\x02\x04", "L", (0, 128, 255)),
+        (b"P5 3 1 257 \x00\x00\x00\x80\x01\x01", "I", (0, 32640, 65535)),
+        # P6 with maxval < 255
+        (
+            b"P6 3 1 17 \x00\x01\x02\x08\x09\x0A\x0F\x10\x11",
+            "RGB",
+            (
+                (0, 15, 30),
+                (120, 135, 150),
+                (225, 240, 255),
+            ),
+        ),
+        # P6 with maxval > 255
+        # Scale down to 255, since there is no RGB mode with more than 8-bit
+        (
+            b"P6 3 1 257 \x00\x00\x00\x01\x00\x02"
+            b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xFF\xFF",
+            "RGB",
+            (
+                (0, 1, 2),
+                (127, 128, 129),
+                (254, 255, 255),
+            ),
+        ),
+    ),
+)
+def test_arbitrary_maxval(data, mode, pixels):
+    fp = BytesIO(data)
+    with Image.open(fp) as im:
+        assert im.size == (3, 1)
+        assert im.mode == mode
+
+        px = im.load()
+        assert tuple(px[x, 0] for x in range(3)) == pixels
+
+
 def test_16bit_pgm():
     with Image.open("Tests/images/16_bit_binary.pgm") as im:
-        im.load()
         assert im.mode == "I"
         assert im.size == (20, 100)
         assert im.get_format_mimetype() == "image/x-portable-graymap"
@@ -32,8 +69,6 @@ def test_16bit_pgm():
 
 def test_16bit_pgm_write(tmp_path):
     with Image.open("Tests/images/16_bit_binary.pgm") as im:
-        im.load()
-
         f = str(tmp_path / "temp.pgm")
         im.save(f, "PPM")
 
@@ -91,19 +126,8 @@ def test_token_too_long(tmp_path):
     assert str(e.value) == "Token too long in file header: b'01234567890'"
 
 
-def test_too_many_colors(tmp_path):
-    path = str(tmp_path / "temp.ppm")
-    with open(path, "wb") as f:
-        f.write(b"P6\n1 1\n1000\n")
-
-    with pytest.raises(ValueError) as e:
-        with Image.open(path):
-            pass
-
-    assert str(e.value) == "Too many colors for band: 1000"
-
-
 def test_truncated_file(tmp_path):
+    # Test EOF in header
     path = str(tmp_path / "temp.pgm")
     with open(path, "w") as f:
         f.write("P6")
@@ -114,6 +138,12 @@ def test_truncated_file(tmp_path):
 
     assert str(e.value) == "Reached EOF while reading header"
 
+    # Test EOF for PyDecoder
+    fp = BytesIO(b"P5 3 1 4")
+    with Image.open(fp) as im:
+        with pytest.raises(ValueError):
+            im.load()
+
 
 def test_neg_ppm():
     # Storage.c accepted negative values for xsize, ysize.  the