Repository: python-pillow/Pillow

Problem statement:
Can't save PNG to sys.stdout
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do? 
Just tried to run simple code (ImageDraw)

### What did you expect to happen?

### What actually happened? 
Exception looks very basic:
```  File "/data/user/0/ru.iiec.pydroid3/files/aarch64-linux-android/lib/python3.8/site-packages/PIL/Image.py", line 2164, in save
    save_handler(self, fp, filename)
  File "/data/user/0/ru.iiec.pydroid3/files/aarch64-linux-android/lib/python3.8/site-packages/PIL/PngImagePlugin.py", line 1225, in _save
    fp.write(_MAGIC)
TypeError: write() argument must be str, not bytes
```

### What are your OS, Python and Pillow versions?

* OS: Android app, "pydroid3" (I don't have a computer now to check it)
* Python: 3.8.3
* Pillow: 8.1.2

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->

```python
import sys
from PIL import Image, ImageDraw

# list of dots, in the following format: [x, y, x, y, x, y,...]
first = (146, 399, 163, 403, 170, 393, 169, 391, 166, 386, 170, 381, 170) #.... long tuple

with Image.open("draw image.jpg") as img:

    draw = ImageDraw.Draw(img)
    for i in range(len(first)//2 - 1):
    	draw.line(first[2*i : 2*i + 4])

    # write to stdout
    img.save(sys.stdout, "PNG")
```
Sorry if I didn't understand the concept... This is my first issue ever...


Hints:
Hi. You're free to say that Pillow should be able to work with your code, and that you'd like it to be fixed in the next Pillow release - but if you'd like an immediate solution, I'd suggest changing `sys.stdout` to `sys.stdout.buffer`.

```python
import sys
from PIL import Image, ImageDraw

# list of dots, in the following format: [x, y, x, y, x, y,...]
first = (146, 399, 163, 403, 170, 393, 169, 391, 166, 386, 170, 381, 170) #.... long tuple

with Image.open("draw image.jpg") as img:

    draw = ImageDraw.Draw(img)
    for i in range(len(first)//2 - 1):
    	draw.line(first[2*i : 2*i + 4])

    # write to stdout
    img.save(sys.stdout.buffer, "PNG")
```

This makes a difference because while `sys.stdout` requires strings, `sys.stdout.buffer` uses bytes.

```pycon
>>> import sys
>>> sys.stdout.mode
'w'
>>> sys.stdout.buffer.mode
'wb'
```

Patch:
diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py
--- a/src/PIL/EpsImagePlugin.py
+++ b/src/PIL/EpsImagePlugin.py
@@ -354,56 +354,46 @@ def _save(im, fp, filename, eps=1):
     #
     # determine PostScript image mode
     if im.mode == "L":
-        operator = (8, 1, "image")
+        operator = (8, 1, b"image")
     elif im.mode == "RGB":
-        operator = (8, 3, "false 3 colorimage")
+        operator = (8, 3, b"false 3 colorimage")
     elif im.mode == "CMYK":
-        operator = (8, 4, "false 4 colorimage")
+        operator = (8, 4, b"false 4 colorimage")
     else:
         raise ValueError("image mode is not supported")
 
-    base_fp = fp
-    wrapped_fp = False
-    if fp != sys.stdout:
-        fp = io.TextIOWrapper(fp, encoding="latin-1")
-        wrapped_fp = True
-
-    try:
-        if eps:
-            #
-            # write EPS header
-            fp.write("%!PS-Adobe-3.0 EPSF-3.0\n")
-            fp.write("%%Creator: PIL 0.1 EpsEncode\n")
-            # fp.write("%%CreationDate: %s"...)
-            fp.write("%%%%BoundingBox: 0 0 %d %d\n" % im.size)
-            fp.write("%%Pages: 1\n")
-            fp.write("%%EndComments\n")
-            fp.write("%%Page: 1 1\n")
-            fp.write("%%ImageData: %d %d " % im.size)
-            fp.write('%d %d 0 1 1 "%s"\n' % operator)
-
+    if eps:
         #
-        # image header
-        fp.write("gsave\n")
-        fp.write("10 dict begin\n")
-        fp.write(f"/buf {im.size[0] * operator[1]} string def\n")
-        fp.write("%d %d scale\n" % im.size)
-        fp.write("%d %d 8\n" % im.size)  # <= bits
-        fp.write(f"[{im.size[0]} 0 0 -{im.size[1]} 0 {im.size[1]}]\n")
-        fp.write("{ currentfile buf readhexstring pop } bind\n")
-        fp.write(operator[2] + "\n")
-        if hasattr(fp, "flush"):
-            fp.flush()
-
-        ImageFile._save(im, base_fp, [("eps", (0, 0) + im.size, 0, None)])
-
-        fp.write("\n%%%%EndBinary\n")
-        fp.write("grestore end\n")
-        if hasattr(fp, "flush"):
-            fp.flush()
-    finally:
-        if wrapped_fp:
-            fp.detach()
+        # write EPS header
+        fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
+        fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
+        # fp.write("%%CreationDate: %s"...)
+        fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
+        fp.write(b"%%Pages: 1\n")
+        fp.write(b"%%EndComments\n")
+        fp.write(b"%%Page: 1 1\n")
+        fp.write(b"%%ImageData: %d %d " % im.size)
+        fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
+
+    #
+    # image header
+    fp.write(b"gsave\n")
+    fp.write(b"10 dict begin\n")
+    fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
+    fp.write(b"%d %d scale\n" % im.size)
+    fp.write(b"%d %d 8\n" % im.size)  # <= bits
+    fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
+    fp.write(b"{ currentfile buf readhexstring pop } bind\n")
+    fp.write(operator[2] + b"\n")
+    if hasattr(fp, "flush"):
+        fp.flush()
+
+    ImageFile._save(im, fp, [("eps", (0, 0) + im.size, 0, None)])
+
+    fp.write(b"\n%%%%EndBinary\n")
+    fp.write(b"grestore end\n")
+    if hasattr(fp, "flush"):
+        fp.flush()
 
 
 #
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -2135,6 +2135,11 @@ def save(self, fp, format=None, **params):
         elif isinstance(fp, Path):
             filename = str(fp)
             open_fp = True
+        elif fp == sys.stdout:
+            try:
+                fp = sys.stdout.buffer
+            except AttributeError:
+                pass
         if not filename and hasattr(fp, "name") and isPath(fp.name):
             # only set the name for metadata purposes
             filename = fp.name
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -493,7 +493,7 @@ def _save(im, fp, tile, bufsize=0):
     # But, it would need at least the image size in most cases. RawEncode is
     # a tricky case.
     bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4)  # see RawEncode.c
-    if fp == sys.stdout:
+    if fp == sys.stdout or (hasattr(sys.stdout, "buffer") and fp == sys.stdout.buffer):
         fp.flush()
         return
     try:
diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py
--- a/src/PIL/PSDraw.py
+++ b/src/PIL/PSDraw.py
@@ -26,39 +26,36 @@
 class PSDraw:
     """
     Sets up printing to the given file. If ``fp`` is omitted,
-    :py:data:`sys.stdout` is assumed.
+    ``sys.stdout.buffer`` or ``sys.stdout`` is assumed.
     """
 
     def __init__(self, fp=None):
         if not fp:
-            fp = sys.stdout
+            try:
+                fp = sys.stdout.buffer
+            except AttributeError:
+                fp = sys.stdout
         self.fp = fp
 
-    def _fp_write(self, to_write):
-        if self.fp == sys.stdout:
-            self.fp.write(to_write)
-        else:
-            self.fp.write(bytes(to_write, "UTF-8"))
-
     def begin_document(self, id=None):
         """Set up printing of a document. (Write PostScript DSC header.)"""
         # FIXME: incomplete
-        self._fp_write(
-            "%!PS-Adobe-3.0\n"
-            "save\n"
-            "/showpage { } def\n"
-            "%%EndComments\n"
-            "%%BeginDocument\n"
+        self.fp.write(
+            b"%!PS-Adobe-3.0\n"
+            b"save\n"
+            b"/showpage { } def\n"
+            b"%%EndComments\n"
+            b"%%BeginDocument\n"
         )
-        # self._fp_write(ERROR_PS)  # debugging!
-        self._fp_write(EDROFF_PS)
-        self._fp_write(VDI_PS)
-        self._fp_write("%%EndProlog\n")
+        # self.fp.write(ERROR_PS)  # debugging!
+        self.fp.write(EDROFF_PS)
+        self.fp.write(VDI_PS)
+        self.fp.write(b"%%EndProlog\n")
         self.isofont = {}
 
     def end_document(self):
         """Ends printing. (Write PostScript DSC footer.)"""
-        self._fp_write("%%EndDocument\nrestore showpage\n%%End\n")
+        self.fp.write(b"%%EndDocument\nrestore showpage\n%%End\n")
         if hasattr(self.fp, "flush"):
             self.fp.flush()
 
@@ -69,12 +66,13 @@ def setfont(self, font, size):
         :param font: A PostScript font name
         :param size: Size in points.
         """
+        font = bytes(font, "UTF-8")
         if font not in self.isofont:
             # reencode font
-            self._fp_write(f"/PSDraw-{font} ISOLatin1Encoding /{font} E\n")
+            self.fp.write(b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font, font))
             self.isofont[font] = 1
         # rough
-        self._fp_write(f"/F0 {size} /PSDraw-{font} F\n")
+        self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font))
 
     def line(self, xy0, xy1):
         """
@@ -82,7 +80,7 @@ def line(self, xy0, xy1):
         PostScript point coordinates (72 points per inch, (0, 0) is the lower
         left corner of the page).
         """
-        self._fp_write("%d %d %d %d Vl\n" % (*xy0, *xy1))
+        self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1))
 
     def rectangle(self, box):
         """
@@ -97,16 +95,18 @@ def rectangle(self, box):
 
                         %d %d M %d %d 0 Vr\n
         """
-        self._fp_write("%d %d M %d %d 0 Vr\n" % box)
+        self.fp.write(b"%d %d M %d %d 0 Vr\n" % box)
 
     def text(self, xy, text):
         """
         Draws text at the given position. You must use
         :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method.
         """
-        text = "\\(".join(text.split("("))
-        text = "\\)".join(text.split(")"))
-        self._fp_write(f"{xy[0]} {xy[1]} M ({text}) S\n")
+        text = bytes(text, "UTF-8")
+        text = b"\\(".join(text.split(b"("))
+        text = b"\\)".join(text.split(b")"))
+        xy += (text,)
+        self.fp.write(b"%d %d M (%s) S\n" % xy)
 
     def image(self, box, im, dpi=None):
         """Draw a PIL image, centered in the given box."""
@@ -130,14 +130,14 @@ def image(self, box, im, dpi=None):
             y = ymax
         dx = (xmax - x) / 2 + box[0]
         dy = (ymax - y) / 2 + box[1]
-        self._fp_write(f"gsave\n{dx:f} {dy:f} translate\n")
+        self.fp.write(b"gsave\n%f %f translate\n" % (dx, dy))
         if (x, y) != im.size:
             # EpsImagePlugin._save prints the image at (0,0,xsize,ysize)
             sx = x / im.size[0]
             sy = y / im.size[1]
-            self._fp_write(f"{sx:f} {sy:f} scale\n")
+            self.fp.write(b"%f %f scale\n" % (sx, sy))
         EpsImagePlugin._save(im, self.fp, None, 0)
-        self._fp_write("\ngrestore\n")
+        self.fp.write(b"\ngrestore\n")
 
 
 # --------------------------------------------------------------------
@@ -153,7 +153,7 @@ def image(self, box, im, dpi=None):
 #
 
 
-EDROFF_PS = """\
+EDROFF_PS = b"""\
 /S { show } bind def
 /P { moveto show } bind def
 /M { moveto } bind def
@@ -182,7 +182,7 @@ def image(self, box, im, dpi=None):
 # Copyright (c) Fredrik Lundh 1994.
 #
 
-VDI_PS = """\
+VDI_PS = b"""\
 /Vm { moveto } bind def
 /Va { newpath arcn stroke } bind def
 /Vl { moveto lineto stroke } bind def
@@ -207,7 +207,7 @@ def image(self, box, im, dpi=None):
 # 89-11-21 fl: created (pslist 1.10)
 #
 
-ERROR_PS = """\
+ERROR_PS = b"""\
 /landscape false def
 /errorBUF 200 string def
 /errorNL { currentpoint 10 sub exch pop 72 exch moveto } def


Test patch:
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -1,4 +1,5 @@
 import re
+import sys
 import zlib
 from io import BytesIO
 
@@ -10,6 +11,7 @@
     PillowLeakTestCase,
     assert_image,
     assert_image_equal,
+    assert_image_equal_tofile,
     hopper,
     is_big_endian,
     is_win32,
@@ -711,6 +713,32 @@ def test_seek(self):
             with pytest.raises(EOFError):
                 im.seek(1)
 
+    @pytest.mark.parametrize("buffer", (True, False))
+    def test_save_stdout(self, buffer):
+        old_stdout = sys.stdout.buffer
+
+        if buffer:
+
+            class MyStdOut:
+                buffer = BytesIO()
+
+            mystdout = MyStdOut()
+        else:
+            mystdout = BytesIO()
+
+        sys.stdout = mystdout
+
+        with Image.open(TEST_PNG_FILE) as im:
+            im.save(sys.stdout, "PNG")
+
+        # Reset stdout
+        sys.stdout = old_stdout
+
+        if buffer:
+            mystdout = mystdout.buffer
+        reloaded = Image.open(mystdout)
+        assert_image_equal_tofile(reloaded, TEST_PNG_FILE)
+
 
 @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS")
 @skip_unless_feature("zlib")
diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py
--- a/Tests/test_psdraw.py
+++ b/Tests/test_psdraw.py
@@ -1,6 +1,8 @@
 import os
 import sys
-from io import StringIO
+from io import BytesIO
+
+import pytest
 
 from PIL import Image, PSDraw
 
@@ -44,10 +46,21 @@ def test_draw_postscript(tmp_path):
     assert os.path.getsize(tempfile) > 0
 
 
-def test_stdout():
+@pytest.mark.parametrize("buffer", (True, False))
+def test_stdout(buffer):
     # Temporarily redirect stdout
-    old_stdout = sys.stdout
-    sys.stdout = mystdout = StringIO()
+    old_stdout = sys.stdout.buffer
+
+    if buffer:
+
+        class MyStdOut:
+            buffer = BytesIO()
+
+        mystdout = MyStdOut()
+    else:
+        mystdout = BytesIO()
+
+    sys.stdout = mystdout
 
     ps = PSDraw.PSDraw()
     _create_document(ps)
@@ -55,4 +68,6 @@ def test_stdout():
     # Reset stdout
     sys.stdout = old_stdout
 
-    assert mystdout.getvalue() != ""
+    if buffer:
+        mystdout = mystdout.buffer
+    assert mystdout.getvalue() != b""