codecov[bot]: # [Codecov](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=h1) Report
> Merging [#4292](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=desc) into [master](https://codecov.io/gh/python-pillow/Pillow/commit/4dcd194333126a9163ab8482ac84783ee1b2d3df?src=pr&el=desc) will **decrease** coverage by `0.29%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/python-pillow/Pillow/pull/4292/graphs/tree.svg?width=650&token=OstRzgUJ8y&height=150&src=pr)](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=tree)

```diff
@@           Coverage Diff            @@
##           master   #4292     +/-   ##
========================================
- Coverage    89.8%   89.5%   -0.3%     
========================================
  Files         329     324      -5     
  Lines       40898   40052    -846     
  Branches        0    3199   +3199     
========================================
- Hits        36728   35850    -878     
- Misses       4170    4199     +29     
- Partials        0       3      +3
```

| Flag | Coverage Δ | |
|---|---|---|
| #AppVeyor | `88.85% <100%> (-0.63%)` | :arrow_down: |
| #GHA_Docker | `85.86% <100%> (-0.05%)` | :arrow_down: |
| #GHA_Ubuntu | `87.91% <100%> (-0.1%)` | :arrow_down: |
| #GHA_Windows | `90.85% <100%> (-0.15%)` | :arrow_down: |
| #GHA_macOS | `86.88% <100%> (-0.12%)` | :arrow_down: |
| #TravisCI | `85.88% <100%> (-6.79%)` | :arrow_down: |

| [Impacted Files](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [Tests/test\_file\_png.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-VGVzdHMvdGVzdF9maWxlX3BuZy5weQ==) | `99.76% <100%> (-0.24%)` | :arrow_down: |
| [src/PIL/PngImagePlugin.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9QbmdJbWFnZVBsdWdpbi5weQ==) | `90.61% <100%> (-1.27%)` | :arrow_down: |
| [Tests/test\_imagefont\_bitmap.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-VGVzdHMvdGVzdF9pbWFnZWZvbnRfYml0bWFwLnB5) | `91.3% <0%> (-8.7%)` | :arrow_down: |
| [src/PIL/BmpImagePlugin.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9CbXBJbWFnZVBsdWdpbi5weQ==) | `94.52% <0%> (-3.36%)` | :arrow_down: |
| [Tests/test\_features.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-VGVzdHMvdGVzdF9mZWF0dXJlcy5weQ==) | `96.92% <0%> (-3.08%)` | :arrow_down: |
| [src/PIL/ImageFont.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9JbWFnZUZvbnQucHk=) | `93.36% <0%> (-2.79%)` | :arrow_down: |
| [src/PIL/features.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9mZWF0dXJlcy5weQ==) | `95.78% <0%> (-2.11%)` | :arrow_down: |
| [Tests/test\_file\_webp.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-VGVzdHMvdGVzdF9maWxlX3dlYnAucHk=) | `98% <0%> (-2%)` | :arrow_down: |
| [src/PIL/ImImagePlugin.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9JbUltYWdlUGx1Z2luLnB5) | `80.44% <0%> (-1.84%)` | :arrow_down: |
| [src/PIL/GifImagePlugin.py](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree#diff-c3JjL1BJTC9HaWZJbWFnZVBsdWdpbi5weQ==) | `96.06% <0%> (-1.71%)` | :arrow_down: |
| ... and [65 more](https://codecov.io/gh/python-pillow/Pillow/pull/4292/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=footer). Last update [4dcd194...99fdc6c](https://codecov.io/gh/python-pillow/Pillow/pull/4292?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index a12de82e2..0b7660f19 100644
--- a/docs/handbook/image-file-formats.rst
+++ b/docs/handbook/image-file-formats.rst
@@ -527,7 +527,7 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
     A tuple of two numbers corresponding to the desired dpi in each direction.
 
 **pnginfo**
-    A :py:class:`PIL.PngImagePlugin.PngInfo` instance containing text tags.
+    A :py:class:`PIL.PngImagePlugin.PngInfo` instance containing chunks.
 
 **compress_level**
     ZLIB compression level, a number between 0 and 9: 1 gives best speed,
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index ee9d52b4c..c2bb080c9 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -228,15 +228,20 @@ class PngInfo:
     def __init__(self):
         self.chunks = []
 
-    def add(self, cid, data):
+    def add(self, cid, data, after_idat=False):
         """Appends an arbitrary chunk. Use with caution.
 
         :param cid: a byte string, 4 bytes long.
         :param data: a byte string of the encoded data
+        :param after_idat: for use with private chunks. Whether the chunk
+                           should be written after IDAT
 
         """
 
-        self.chunks.append((cid, data))
+        chunk = [cid, data]
+        if after_idat:
+            chunk.append(True)
+        self.chunks.append(tuple(chunk))
 
     def add_itxt(self, key, value, lang="", tkey="", zip=False):
         """Appends an iTXt chunk.
@@ -641,6 +646,7 @@ class PngImageFile(ImageFile.ImageFile):
         #
         # Parse headers up to the first IDAT or fDAT chunk
 
+        self.private_chunks = []
         self.png = PngStream(self.fp)
 
         while True:
@@ -657,6 +663,8 @@ class PngImageFile(ImageFile.ImageFile):
             except AttributeError:
                 logger.debug("%r %s %s (unknown)", cid, pos, length)
                 s = ImageFile._safe_read(self.fp, length)
+                if cid[1:2].islower():
+                    self.private_chunks.append((cid, s))
 
             self.png.crc(cid, s)
 
@@ -888,7 +896,9 @@ class PngImageFile(ImageFile.ImageFile):
                 ImageFile._safe_read(self.fp, length)
             except AttributeError:
                 logger.debug("%r %s %s (unknown)", cid, pos, length)
-                ImageFile._safe_read(self.fp, length)
+                s = ImageFile._safe_read(self.fp, length)
+                if cid[1:2].islower():
+                    self.private_chunks.append((cid, s, True))
         self._text = self.png.im_text
         if not self.is_animated:
             self.png.close()
@@ -1217,12 +1227,18 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     info = im.encoderinfo.get("pnginfo")
     if info:
         chunks_multiple_allowed = [b"sPLT", b"iTXt", b"tEXt", b"zTXt"]
-        for cid, data in info.chunks:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
             if cid in chunks:
                 chunks.remove(cid)
                 chunk(fp, cid, data)
             elif cid in chunks_multiple_allowed:
                 chunk(fp, cid, data)
+            elif cid[1:2].islower():
+                # Private chunk
+                after_idat = info_chunk[2:3]
+                if not after_idat:
+                    chunk(fp, cid, data)
 
     if im.mode == "P":
         palette_byte_number = (2 ** bits) * 3
@@ -1272,7 +1288,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
 
     if info:
         chunks = [b"bKGD", b"hIST"]
-        for cid, data in info.chunks:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
             if cid in chunks:
                 chunks.remove(cid)
                 chunk(fp, cid, data)
@@ -1290,6 +1307,15 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     else:
         ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)])
 
+    if info:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
+            if cid[1:2].islower():
+                # Private chunk
+                after_idat = info_chunk[2:3]
+                if after_idat:
+                    chunk(fp, cid, data)
+
     chunk(fp, b"IEND", b"")
 
     if hasattr(fp, "flush"):

Allow non-standard chunks to be saved in PNG files
### What did you do?
I'm making a simple tool that's modifying sprites for ZDoom source port of Doom video game. It uses non-standard PNG chunks to store additional information, eg sprite offsets.

```python
import struct
from PIL import Image
from PIL.PngImagePlugin import PngInfo

img = Image.open('src.png')
pnginfo = PngInfo()
pnginfo.add(b'grAb', struct.pack('>II', 10, 49))
img.save('dst.png', pnginfo=pnginfo)
```

### What did you expect to happen?
To see a 'grAb' chunk inside saved PNG file (like source image has)

### What actually happened?
Chunk have not been saved because there is a forced list of chunks that's being saved inside `src/PIL/PngImagePlugin.py`

### What are your OS, Python and Pillow versions?

* OS: GNU/Linux
* Python: 3.8.0
* Pillow: 6.2.1

As far as I understand PNG specification doesn't strictly forbid saving unknown chunks with "Safe-to-copy bit" set to 0. PIL is great tool for image manipulation automation, I think it would be great to give developers more ways of using it. Thanks!
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
index 476995dd7..e20e247fe 100644
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -564,6 +564,28 @@ class TestFilePng:
         chunks = PngImagePlugin.getchunks(im)
         assert len(chunks) == 3
 
+    def test_read_private_chunks(self):
+        im = Image.open("Tests/images/exif.png")
+        assert im.private_chunks == [(b"orNT", b"\x01")]
+
+    def test_roundtrip_private_chunk(self):
+        # Check private chunk roundtripping
+
+        with Image.open(TEST_PNG_FILE) as im:
+            info = PngImagePlugin.PngInfo()
+            info.add(b"prIV", b"VALUE")
+            info.add(b"atEC", b"VALUE2")
+            info.add(b"prIV", b"VALUE3", True)
+
+            im = roundtrip(im, pnginfo=info)
+        assert im.private_chunks == [(b"prIV", b"VALUE"), (b"atEC", b"VALUE2")]
+        im.load()
+        assert im.private_chunks == [
+            (b"prIV", b"VALUE"),
+            (b"atEC", b"VALUE2"),
+            (b"prIV", b"VALUE3", True),
+        ]
+
     def test_textual_chunks_after_idat(self):
         with Image.open("Tests/images/hopper.png") as im:
             assert "comment" in im.text.keys()

pip install -e .[dev]
parse_log_pytest
requirements.txt
apt-get update apt-get install -y gcc libjpeg-dev zlib1g-dev libtiff5-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev libharfbuzz-dev libfribidi-dev libxcb1-dev
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.16
babel==2.17.0
black==25.1.0
blessed==1.20.0
build==1.2.2.post1
certifi==2025.1.31
charset-normalizer==3.4.1
check-manifest==0.50
click==8.1.8
coverage==7.8.0
docutils==0.21.2
exceptiongroup==1.2.2
idna==3.10
imagesize==1.4.1
importlib_metadata==8.6.1
iniconfig==2.1.0
jarn.viewdoc==2.7
Jinja2==3.1.6
MarkupSafe==3.0.2
mypy-extensions==1.0.0
olefile==0.47
packaging==24.2
pathspec==0.12.1
-e git+https://github.com/python-pillow/Pillow.git@e634c4dc0caf3bdcc516a66bcd210c79d9fc9b27#egg=Pillow
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.1
Pygments==2.19.1
pyproject_hooks==1.2.0
pyroma==4.2
pytest==8.3.5
pytest-cov==6.0.0
requests==2.32.3
six==1.17.0
snowballstemmer==2.2.0
Sphinx==7.4.7
sphinx-rtd-theme==3.0.2
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
tomli==2.2.1
trove-classifiers==2025.3.19.19
typing_extensions==4.13.0
urllib3==2.3.0
wcwidth==0.2.13
zipp==3.21.0

name: Pillow
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.16
      - babel==2.17.0
      - black==25.1.0
      - blessed==1.20.0
      - build==1.2.2.post1
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - check-manifest==0.50
      - click==8.1.8
      - coverage==7.8.0
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - jarn-viewdoc==2.7
      - jinja2==3.1.6
      - markupsafe==3.0.2
      - mypy-extensions==1.0.0
      - olefile==0.47
      - packaging==24.2
      - pathspec==0.12.1
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pygments==2.19.1
      - pyproject-hooks==1.2.0
      - pyroma==4.2
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - requests==2.32.3
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==7.4.7
      - sphinx-rtd-theme==3.0.2
      - sphinxcontrib-applehelp==2.0.0
      - sphinxcontrib-devhelp==2.0.0
      - sphinxcontrib-htmlhelp==2.1.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==2.0.0
      - sphinxcontrib-serializinghtml==2.0.0
      - tomli==2.2.1
      - trove-classifiers==2025.3.19.19
      - typing-extensions==4.13.0
      - urllib3==2.3.0
      - wcwidth==0.2.13
      - zipp==3.21.0
prefix: /opt/conda/envs/Pillow

Tests/test_file_png.py::TestFilePng::test_read_private_chunks Tests/test_file_png.py::TestFilePng::test_roundtrip_private_chunk
Tests/test_file_png.py::TestFilePng::test_load_verify
Tests/test_file_png.py::TestFilePng::test_sanity Tests/test_file_png.py::TestFilePng::test_invalid_file Tests/test_file_png.py::TestFilePng::test_broken Tests/test_file_png.py::TestFilePng::test_bad_text Tests/test_file_png.py::TestFilePng::test_bad_ztxt Tests/test_file_png.py::TestFilePng::test_bad_itxt Tests/test_file_png.py::TestFilePng::test_interlace Tests/test_file_png.py::TestFilePng::test_load_transparent_p Tests/test_file_png.py::TestFilePng::test_load_transparent_rgb Tests/test_file_png.py::TestFilePng::test_save_p_transparent_palette Tests/test_file_png.py::TestFilePng::test_save_p_single_transparency Tests/test_file_png.py::TestFilePng::test_save_p_transparent_black Tests/test_file_png.py::TestFilePng::test_save_greyscale_transparency Tests/test_file_png.py::TestFilePng::test_save_rgb_single_transparency Tests/test_file_png.py::TestFilePng::test_verify_struct_error Tests/test_file_png.py::TestFilePng::test_verify_ignores_crc_error Tests/test_file_png.py::TestFilePng::test_verify_not_ignores_crc_error_in_required_chunk Tests/test_file_png.py::TestFilePng::test_roundtrip_dpi Tests/test_file_png.py::TestFilePng::test_load_dpi_rounding Tests/test_file_png.py::TestFilePng::test_save_dpi_rounding Tests/test_file_png.py::TestFilePng::test_roundtrip_text Tests/test_file_png.py::TestFilePng::test_roundtrip_itxt Tests/test_file_png.py::TestFilePng::test_nonunicode_text Tests/test_file_png.py::TestFilePng::test_unicode_text Tests/test_file_png.py::TestFilePng::test_scary Tests/test_file_png.py::TestFilePng::test_trns_rgb Tests/test_file_png.py::TestFilePng::test_trns_p Tests/test_file_png.py::TestFilePng::test_trns_null Tests/test_file_png.py::TestFilePng::test_save_icc_profile Tests/test_file_png.py::TestFilePng::test_discard_icc_profile Tests/test_file_png.py::TestFilePng::test_roundtrip_icc_profile Tests/test_file_png.py::TestFilePng::test_roundtrip_no_icc_profile Tests/test_file_png.py::TestFilePng::test_repr_png Tests/test_file_png.py::TestFilePng::test_chunk_order Tests/test_file_png.py::TestFilePng::test_getchunks Tests/test_file_png.py::TestFilePng::test_textual_chunks_after_idat Tests/test_file_png.py::TestFilePng::test_exif[Tests/images/exif.png] Tests/test_file_png.py::TestFilePng::test_exif[Tests/images/exif_imagemagick.png] Tests/test_file_png.py::TestFilePng::test_exif_save Tests/test_file_png.py::TestFilePng::test_exif_from_jpg Tests/test_file_png.py::TestFilePng::test_exif_argument Tests/test_file_png.py::TestFilePng::test_tell Tests/test_file_png.py::TestFilePng::test_seek Tests/test_file_png.py::TestTruncatedPngPLeaks::test_leak_load
docs/handbook/image-file-formats.rst src/PIL/PngImagePlugin.py
docs/handbook/image-file-formats.rst src/PIL/PngImagePlugin.py