Repository: python-pillow/Pillow

Issue:
GIF comment blocks should be placed near front of file, once
<!--
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?

Opened an animated GIF, wrote it out with a `comment=` argument to `.save`.

### What did you expect to happen?

I expected the comment to be written once, preferably at the beginning of the file, per advice in (GIF89a spec)[https://www.w3.org/Graphics/GIF/spec-gif89a.txt] 

> it is recommended that Comment Extensions do not interfere with Control or Data blocks; they should be located at the beginning or at the end of the Data Stream to the extent possible.

### What actually happened?
The comment was repeated in every frame.

### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.10.4
* Pillow: 9.1.0

<!--
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.
-->

![dispose_prev](https://user-images.githubusercontent.com/24783736/168431895-fe11d28e-ae4e-46d2-aed0-e5f6a73565ea.gif)

```python
from PIL import Image, ImageSequence
with Image.open("dispose_prev.gif") as im:
    frames = ImageSequence.all_frames(im)
    frames[0].save("testout.gif", save_all=True, append_images=frames[1:], comment="Test comment")
    with Image.open("testout.gif") as img:
        for k, frame in enumerate(ImageSequence.Iterator(img)):
            if "comment" in frame.info:
                print(f'{k=} {frame.info["comment"]=}')
```

Output with 9.1.0:

```
k=0 frame.info["comment"]=b'Test comment'
k=1 frame.info["comment"]=b'Test comment'
k=2 frame.info["comment"]=b'Test comment'
k=3 frame.info["comment"]=b'Test comment'
k=4 frame.info["comment"]=b'Test comment'
```
#### Discussion:

Pillow's handling of GIF comments needs to be changed. A comment can be specified with `comment="..."` arg to `.save()` or by putting a comment in the `.info` dict of the Image object being saved. It is now being written in every frame, and placed after the Graphic Control Extension block (if one is written) and before the Image Descriptor. The placement is not prohibited by the standard, but is contrary to the "Recommendations" in part 24e of the standard and also to the GIF Grammar in Appendix B.

I have written  PR #6300 to resolve this. It will write the comment once only, before the first Graphic Control Block (if any) and the first Image Descriptor. After PR #6211 is merged, this should cause the comment to appear just after the "NETSCAPE2.0" looping control block, if one is written.


Discussion:

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -163,6 +163,8 @@ def _seek(self, frame, update_image=True):
             self.__frame = -1
             self._fp.seek(self.__rewind)
             self.disposal_method = 0
+            if "comment" in self.info:
+                del self.info["comment"]
         else:
             # ensure that the previous frame was loaded
             if self.tile and update_image:
@@ -230,7 +232,7 @@ def _seek(self, frame, update_image=True):
                     #
                     comment = b""
 
-                    # Collect one comment block
+                    # Read this comment block
                     while block:
                         comment += block
                         block = self.data()
@@ -395,7 +397,9 @@ def _rgb(color):
                 )
             ]
 
-        for k in ["duration", "comment", "extension", "loop"]:
+        if info.get("comment"):
+            self.info["comment"] = info["comment"]
+        for k in ["duration", "extension", "loop"]:
             if k in info:
                 self.info[k] = info[k]
             elif k in self.info:
@@ -712,15 +716,6 @@ def _write_local_header(fp, im, offset, flags):
             + o8(0)
         )
 
-    if "comment" in im.encoderinfo and 1 <= len(im.encoderinfo["comment"]):
-        fp.write(b"!" + o8(254))  # extension intro
-        comment = im.encoderinfo["comment"]
-        if isinstance(comment, str):
-            comment = comment.encode()
-        for i in range(0, len(comment), 255):
-            subblock = comment[i : i + 255]
-            fp.write(o8(len(subblock)) + subblock)
-        fp.write(o8(0))
     if "loop" in im.encoderinfo:
         number_of_loops = im.encoderinfo["loop"]
         fp.write(
@@ -925,7 +920,7 @@ def _get_global_header(im, info):
     palette_bytes = _get_palette_bytes(im)
     color_table_size = _get_color_table_size(palette_bytes)
 
-    return [
+    header = [
         b"GIF"  # signature
         + version  # version
         + o16(im.size[0])  # canvas width
@@ -938,6 +933,19 @@ def _get_global_header(im, info):
         # Global Color Table
         _get_header_palette(palette_bytes),
     ]
+    if info.get("comment"):
+        comment_block = b"!" + o8(254)  # extension intro
+
+        comment = info["comment"]
+        if isinstance(comment, str):
+            comment = comment.encode()
+        for i in range(0, len(comment), 255):
+            subblock = comment[i : i + 255]
+            comment_block += o8(len(subblock)) + subblock
+
+        comment_block += o8(0)
+        header.append(comment_block)
+    return header
 
 
 def _write_frame_data(fp, im_frame, offset, params):


Test patch:
diff --git a/Tests/images/second_frame_comment.gif b/Tests/images/second_frame_comment.gif
new file mode 100644
Binary files /dev/null and b/Tests/images/second_frame_comment.gif differ
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -837,6 +837,50 @@ def test_read_multiple_comment_blocks():
         assert im.info["comment"] == b"Test comment 1\nTest comment 2"
 
 
+def test_empty_string_comment(tmp_path):
+    out = str(tmp_path / "temp.gif")
+    with Image.open("Tests/images/chi.gif") as im:
+        assert "comment" in im.info
+
+        # Empty string comment should suppress existing comment
+        im.save(out, save_all=True, comment="")
+
+    with Image.open(out) as reread:
+        for frame in ImageSequence.Iterator(reread):
+            assert "comment" not in frame.info
+
+
+def test_retain_comment_in_subsequent_frames(tmp_path):
+    # Test that a comment block at the beginning is kept
+    with Image.open("Tests/images/chi.gif") as im:
+        for frame in ImageSequence.Iterator(im):
+            assert frame.info["comment"] == b"Created with GIMP"
+
+    with Image.open("Tests/images/second_frame_comment.gif") as im:
+        assert "comment" not in im.info
+
+        # Test that a comment in the middle is read
+        im.seek(1)
+        assert im.info["comment"] == b"Comment in the second frame"
+
+        # Test that it is still present in a later frame
+        im.seek(2)
+        assert im.info["comment"] == b"Comment in the second frame"
+
+        # Test that rewinding removes the comment
+        im.seek(0)
+        assert "comment" not in im.info
+
+    # Test that a saved image keeps the comment
+    out = str(tmp_path / "temp.gif")
+    with Image.open("Tests/images/dispose_prev.gif") as im:
+        im.save(out, save_all=True, comment="Test")
+
+    with Image.open(out) as reread:
+        for frame in ImageSequence.Iterator(reread):
+            assert frame.info["comment"] == b"Test"
+
+
 def test_version(tmp_path):
     out = str(tmp_path / "temp.gif")