Repository navigation
Fix saving CMYK or keep_rgb MPO images - #10328
Conversation
Can you link to the part of the libjpeg-turbo source code where it determines which header to use? |
|
Yes — the default selection is in libjpeg-turbo's On Pillow's side, While checking this I found an omission in my patch: Pillow also enables JFIF when both rounded DPI values are positive, so CMYK and |
|
I think you'll agree that the conditions to determine the position are getting a little complicated. How about just searching through the output instead? I've pushed a commit. See what you think. |
|
Thanks, the search is much simpler. I found one compatibility issue with reading the output, though: callers can pass a write-only binary file to from PIL import Image
first = Image.new("RGB", (16, 8))
second = Image.new("RGB", (24, 16))
with open("out.mpo", "wb") as f:
first.save(f, "MPO", save_all=True, append_images=[second])With the new writer this raises |
|
I pushed ab7ad7e to capture the 20-byte search window as the first JPEG is written. That keeps the marker search and lets write-only files work. I added three cases that fail on the previous head and pass with the change. One trade-off: the first frame now uses the Python file-object encoding path instead of the direct file-descriptor path. I checked 288 mode/EXIF/DPI/stream combinations and 60 multi-frame/metadata cases with the exact writer and the existing Pillow 13.0 codec; the full branch CI is running. |
|
I've created #10328 with my next suggestion. |
|
Thanks, buffering the first frame makes the write-only case simpler. I found one edge case in the proposed commit: from io import BytesIO
import struct
from PIL import Image
ifd_length = 66 + 16 * 2
placeholder = b"\xff\xe2" + struct.pack(">H", 6 + ifd_length) + b"MPF\0" + b" " * ifd_length
exif = Image.Exif()
exif[0x9286] = b"ASCII\0\0\0" + placeholder
out = BytesIO()
Image.new("RGB", (16, 8)).save(
out, "MPO", save_all=True, append_images=[Image.new("RGB", (24, 16))],
exif=exif.tobytes(),
)
out.seek(0)
with Image.open(out) as im:
print(im.format, getattr(im, "n_frames", 1)) # JPEG 1, expected MPO 2This EXIF value is deliberately constructed; ordinary EXIF worked in my checks. Could the search start after the EXIF segment, or walk the JPEG markers instead? I checked the proposed |
|
Thanks for the buffering suggestion. I merged your commit and pushed The first compressed frame is now held in memory, as in your suggestion. |
I think this has crossed the line from 'edge case' into 'shooting yourself in the foot'. I don't like leaving a comment in the code that 'The EXIF payload can itself contain the placeholder bytes' because it gives the impression that this is a realistic concern. This PR is only becoming more obscure over time. I'm not giving up, but I would like to ask - what motivated you to create this? Will it help you in any way? Or did AI just find a bug and you created the PR? |
|
Codex found this during a contribution search; it wasn't prompted by a failure in an application or an affected user's file. The CMYK/keep_rgb round-trip failure was reproducible, but I should have made that context clearer when opening the PR. You're right about the EXIF example. I constructed the payload specifically to match the placeholder, then gave it too much weight in the implementation. I've removed the guard, comment and that test in 58f4307. Your buffering approach remains, along with the CMYK and write-only regression tests. Thanks for the time you've put into reviewing and simplifying this. |
Saving a CMYK MPO, or an RGB MPO with
keep_rgb=True, can place the MP Index at the wrong offset. Pillow then reopens it as a single JPEG instead of exposing the other frames. This was found through a Codex-assisted source investigation, rather than an affected user's file.The writer buffers the encoded first frame, finds its MP Index placeholder and writes the index there. This is @radarhere's suggested approach. It supports write-only destinations and holds the compressed first frame in memory. The current branch includes a CMYK round-trip regression. Related: #10326.
Following review, the constructed EXIF collision case and its extra handling were removed. @radarhere subsequently removed the extra write-only test and release-note entry in
f1b49543; those have not been restored.Validation:
58f4307b0, the exact Python writer passed the CMYK regression and three write-only regression cases, plus 72 combinations of mode, keep_rgb, ordinary EXIF metadata and DPI. Decoded frames matched separately encoded JPEGs. The writer's file is byte-for-byte unchanged atf1b49543; the write-only test is no longer part of the submitted test suite.f1b49543, all 51 GitHub check contexts passed. Earlier58f4307b0runs had a macOS action-download DNS failure and a Windows Python 3.15 job that reached its 45-minute limit while installing Ghostscript, before Pillow built or tested. Those failures remain in the history and were not treated as successful tests.Investigation, implementation and checks assisted by Codex.