1#
2# The Python Imaging Library.
3# $Id$
4#
5# GIF file handling
6#
7# History:
8# 1995-09-01 fl Created
9# 1996-12-14 fl Added interlace support
10# 1996-12-30 fl Added animation support
11# 1997-01-05 fl Added write support, fixed local colour map bug
12# 1997-02-23 fl Make sure to load raster data in getdata()
13# 1997-07-05 fl Support external decoder (0.4)
14# 1998-07-09 fl Handle all modes when saving (0.5)
15# 1998-07-15 fl Renamed offset attribute to avoid name clash
16# 2001-04-16 fl Added rewind support (seek to frame 0) (0.6)
17# 2001-04-17 fl Added palette optimization (0.7)
18# 2002-06-06 fl Added transparency support for save (0.8)
19# 2004-02-24 fl Disable interlacing for small images
20#
21# Copyright (c) 1997-2004 by Secret Labs AB
22# Copyright (c) 1995-2004 by Fredrik Lundh
23#
24# See the README file for information on usage and redistribution.
25#
26from __future__ import annotations
27
28import itertools
29import math
30import os
31import subprocess
32import sys
33from enum import IntEnum
34from functools import cached_property
35from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union
36
37from . import (
38 Image,
39 ImageChops,
40 ImageFile,
41 ImageMath,
42 ImageOps,
43 ImagePalette,
44 ImageSequence,
45)
46from ._binary import i16le as i16
47from ._binary import o8
48from ._binary import o16le as o16
49
50if TYPE_CHECKING:
51 from . import _imaging
52
53
54class LoadingStrategy(IntEnum):
55 """.. versionadded:: 9.1.0"""
56
57 RGB_AFTER_FIRST = 0
58 RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1
59 RGB_ALWAYS = 2
60
61
62#: .. versionadded:: 9.1.0
63LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST
64
65# --------------------------------------------------------------------
66# Identify/read GIF files
67
68
69def _accept(prefix: bytes) -> bool:
70 return prefix[:6] in [b"GIF87a", b"GIF89a"]
71
72
73##
74# Image plugin for GIF images. This plugin supports both GIF87 and
75# GIF89 images.
76
77
78class GifImageFile(ImageFile.ImageFile):
79 format = "GIF"
80 format_description = "Compuserve GIF"
81 _close_exclusive_fp_after_loading = False
82
83 global_palette = None
84
85 def data(self) -> bytes | None:
86 s = self.fp.read(1)
87 if s and s[0]:
88 return self.fp.read(s[0])
89 return None
90
91 def _is_palette_needed(self, p: bytes) -> bool:
92 for i in range(0, len(p), 3):
93 if not (i // 3 == p[i] == p[i + 1] == p[i + 2]):
94 return True
95 return False
96
97 def _open(self) -> None:
98 # Screen
99 s = self.fp.read(13)
100 if not _accept(s):
101 msg = "not a GIF file"
102 raise SyntaxError(msg)
103
104 self.info["version"] = s[:6]
105 self._size = i16(s, 6), i16(s, 8)
106 self.tile = []
107 flags = s[10]
108 bits = (flags & 7) + 1
109
110 if flags & 128:
111 # get global palette
112 self.info["background"] = s[11]
113 # check if palette contains colour indices
114 p = self.fp.read(3 << bits)
115 if self._is_palette_needed(p):
116 p = ImagePalette.raw("RGB", p)
117 self.global_palette = self.palette = p
118
119 self._fp = self.fp # FIXME: hack
120 self.__rewind = self.fp.tell()
121 self._n_frames: int | None = None
122 self._seek(0) # get ready to read first frame
123
124 @property
125 def n_frames(self) -> int:
126 if self._n_frames is None:
127 current = self.tell()
128 try:
129 while True:
130 self._seek(self.tell() + 1, False)
131 except EOFError:
132 self._n_frames = self.tell() + 1
133 self.seek(current)
134 return self._n_frames
135
136 @cached_property
137 def is_animated(self) -> bool:
138 if self._n_frames is not None:
139 return self._n_frames != 1
140
141 current = self.tell()
142 if current:
143 return True
144
145 try:
146 self._seek(1, False)
147 is_animated = True
148 except EOFError:
149 is_animated = False
150
151 self.seek(current)
152 return is_animated
153
154 def seek(self, frame: int) -> None:
155 if not self._seek_check(frame):
156 return
157 if frame < self.__frame:
158 self.im = None
159 self._seek(0)
160
161 last_frame = self.__frame
162 for f in range(self.__frame + 1, frame + 1):
163 try:
164 self._seek(f)
165 except EOFError as e:
166 self.seek(last_frame)
167 msg = "no more images in GIF file"
168 raise EOFError(msg) from e
169
170 def _seek(self, frame: int, update_image: bool = True) -> None:
171 if frame == 0:
172 # rewind
173 self.__offset = 0
174 self.dispose: _imaging.ImagingCore | None = None
175 self.__frame = -1
176 self._fp.seek(self.__rewind)
177 self.disposal_method = 0
178 if "comment" in self.info:
179 del self.info["comment"]
180 else:
181 # ensure that the previous frame was loaded
182 if self.tile and update_image:
183 self.load()
184
185 if frame != self.__frame + 1:
186 msg = f"cannot seek to frame {frame}"
187 raise ValueError(msg)
188
189 self.fp = self._fp
190 if self.__offset:
191 # backup to last frame
192 self.fp.seek(self.__offset)
193 while self.data():
194 pass
195 self.__offset = 0
196
197 s = self.fp.read(1)
198 if not s or s == b";":
199 msg = "no more images in GIF file"
200 raise EOFError(msg)
201
202 palette: ImagePalette.ImagePalette | Literal[False] | None = None
203
204 info: dict[str, Any] = {}
205 frame_transparency = None
206 interlace = None
207 frame_dispose_extent = None
208 while True:
209 if not s:
210 s = self.fp.read(1)
211 if not s or s == b";":
212 break
213
214 elif s == b"!":
215 #
216 # extensions
217 #
218 s = self.fp.read(1)
219 block = self.data()
220 if s[0] == 249 and block is not None:
221 #
222 # graphic control extension
223 #
224 flags = block[0]
225 if flags & 1:
226 frame_transparency = block[3]
227 info["duration"] = i16(block, 1) * 10
228
229 # disposal method - find the value of bits 4 - 6
230 dispose_bits = 0b00011100 & flags
231 dispose_bits = dispose_bits >> 2
232 if dispose_bits:
233 # only set the dispose if it is not
234 # unspecified. I'm not sure if this is
235 # correct, but it seems to prevent the last
236 # frame from looking odd for some animations
237 self.disposal_method = dispose_bits
238 elif s[0] == 254:
239 #
240 # comment extension
241 #
242 comment = b""
243
244 # Read this comment block
245 while block:
246 comment += block
247 block = self.data()
248
249 if "comment" in info:
250 # If multiple comment blocks in frame, separate with \n
251 info["comment"] += b"\n" + comment
252 else:
253 info["comment"] = comment
254 s = None
255 continue
256 elif s[0] == 255 and frame == 0 and block is not None:
257 #
258 # application extension
259 #
260 info["extension"] = block, self.fp.tell()
261 if block[:11] == b"NETSCAPE2.0":
262 block = self.data()
263 if block and len(block) >= 3 and block[0] == 1:
264 self.info["loop"] = i16(block, 1)
265 while self.data():
266 pass
267
268 elif s == b",":
269 #
270 # local image
271 #
272 s = self.fp.read(9)
273
274 # extent
275 x0, y0 = i16(s, 0), i16(s, 2)
276 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
277 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
278 self._size = max(x1, self.size[0]), max(y1, self.size[1])
279 Image._decompression_bomb_check(self._size)
280 frame_dispose_extent = x0, y0, x1, y1
281 flags = s[8]
282
283 interlace = (flags & 64) != 0
284
285 if flags & 128:
286 bits = (flags & 7) + 1
287 p = self.fp.read(3 << bits)
288 if self._is_palette_needed(p):
289 palette = ImagePalette.raw("RGB", p)
290 else:
291 palette = False
292
293 # image data
294 bits = self.fp.read(1)[0]
295 self.__offset = self.fp.tell()
296 break
297 s = None
298
299 if interlace is None:
300 msg = "image not found in GIF frame"
301 raise EOFError(msg)
302
303 self.__frame = frame
304 if not update_image:
305 return
306
307 self.tile = []
308
309 if self.dispose:
310 self.im.paste(self.dispose, self.dispose_extent)
311
312 self._frame_palette = palette if palette is not None else self.global_palette
313 self._frame_transparency = frame_transparency
314 if frame == 0:
315 if self._frame_palette:
316 if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
317 self._mode = "RGBA" if frame_transparency is not None else "RGB"
318 else:
319 self._mode = "P"
320 else:
321 self._mode = "L"
322
323 if not palette and self.global_palette:
324 from copy import copy
325
326 palette = copy(self.global_palette)
327 self.palette = palette
328 else:
329 if self.mode == "P":
330 if (
331 LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
332 or palette
333 ):
334 self.pyaccess = None
335 if "transparency" in self.info:
336 self.im.putpalettealpha(self.info["transparency"], 0)
337 self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
338 self._mode = "RGBA"
339 del self.info["transparency"]
340 else:
341 self._mode = "RGB"
342 self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
343
344 def _rgb(color: int) -> tuple[int, int, int]:
345 if self._frame_palette:
346 if color * 3 + 3 > len(self._frame_palette.palette):
347 color = 0
348 return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
349 else:
350 return (color, color, color)
351
352 self.dispose = None
353 self.dispose_extent = frame_dispose_extent
354 if self.dispose_extent and self.disposal_method >= 2:
355 try:
356 if self.disposal_method == 2:
357 # replace with background colour
358
359 # only dispose the extent in this frame
360 x0, y0, x1, y1 = self.dispose_extent
361 dispose_size = (x1 - x0, y1 - y0)
362
363 Image._decompression_bomb_check(dispose_size)
364
365 # by convention, attempt to use transparency first
366 dispose_mode = "P"
367 color = self.info.get("transparency", frame_transparency)
368 if color is not None:
369 if self.mode in ("RGB", "RGBA"):
370 dispose_mode = "RGBA"
371 color = _rgb(color) + (0,)
372 else:
373 color = self.info.get("background", 0)
374 if self.mode in ("RGB", "RGBA"):
375 dispose_mode = "RGB"
376 color = _rgb(color)
377 self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
378 else:
379 # replace with previous contents
380 if self.im is not None:
381 # only dispose the extent in this frame
382 self.dispose = self._crop(self.im, self.dispose_extent)
383 elif frame_transparency is not None:
384 x0, y0, x1, y1 = self.dispose_extent
385 dispose_size = (x1 - x0, y1 - y0)
386
387 Image._decompression_bomb_check(dispose_size)
388 dispose_mode = "P"
389 color = frame_transparency
390 if self.mode in ("RGB", "RGBA"):
391 dispose_mode = "RGBA"
392 color = _rgb(frame_transparency) + (0,)
393 self.dispose = Image.core.fill(
394 dispose_mode, dispose_size, color
395 )
396 except AttributeError:
397 pass
398
399 if interlace is not None:
400 transparency = -1
401 if frame_transparency is not None:
402 if frame == 0:
403 if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS:
404 self.info["transparency"] = frame_transparency
405 elif self.mode not in ("RGB", "RGBA"):
406 transparency = frame_transparency
407 self.tile = [
408 (
409 "gif",
410 (x0, y0, x1, y1),
411 self.__offset,
412 (bits, interlace, transparency),
413 )
414 ]
415
416 if info.get("comment"):
417 self.info["comment"] = info["comment"]
418 for k in ["duration", "extension"]:
419 if k in info:
420 self.info[k] = info[k]
421 elif k in self.info:
422 del self.info[k]
423
424 def load_prepare(self) -> None:
425 temp_mode = "P" if self._frame_palette else "L"
426 self._prev_im = None
427 if self.__frame == 0:
428 if self._frame_transparency is not None:
429 self.im = Image.core.fill(
430 temp_mode, self.size, self._frame_transparency
431 )
432 elif self.mode in ("RGB", "RGBA"):
433 self._prev_im = self.im
434 if self._frame_palette:
435 self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
436 self.im.putpalette("RGB", *self._frame_palette.getdata())
437 else:
438 self.im = None
439 self._mode = temp_mode
440 self._frame_palette = None
441
442 super().load_prepare()
443
444 def load_end(self) -> None:
445 if self.__frame == 0:
446 if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
447 if self._frame_transparency is not None:
448 self.im.putpalettealpha(self._frame_transparency, 0)
449 self._mode = "RGBA"
450 else:
451 self._mode = "RGB"
452 self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG)
453 return
454 if not self._prev_im:
455 return
456 if self._frame_transparency is not None:
457 self.im.putpalettealpha(self._frame_transparency, 0)
458 frame_im = self.im.convert("RGBA")
459 else:
460 frame_im = self.im.convert("RGB")
461
462 assert self.dispose_extent is not None
463 frame_im = self._crop(frame_im, self.dispose_extent)
464
465 self.im = self._prev_im
466 self._mode = self.im.mode
467 if frame_im.mode == "RGBA":
468 self.im.paste(frame_im, self.dispose_extent, frame_im)
469 else:
470 self.im.paste(frame_im, self.dispose_extent)
471
472 def tell(self) -> int:
473 return self.__frame
474
475
476# --------------------------------------------------------------------
477# Write GIF files
478
479
480RAWMODE = {"1": "L", "L": "L", "P": "P"}
481
482
483def _normalize_mode(im: Image.Image) -> Image.Image:
484 """
485 Takes an image (or frame), returns an image in a mode that is appropriate
486 for saving in a Gif.
487
488 It may return the original image, or it may return an image converted to
489 palette or 'L' mode.
490
491 :param im: Image object
492 :returns: Image object
493 """
494 if im.mode in RAWMODE:
495 im.load()
496 return im
497 if Image.getmodebase(im.mode) == "RGB":
498 im = im.convert("P", palette=Image.Palette.ADAPTIVE)
499 if im.palette.mode == "RGBA":
500 for rgba in im.palette.colors:
501 if rgba[3] == 0:
502 im.info["transparency"] = im.palette.colors[rgba]
503 break
504 return im
505 return im.convert("L")
506
507
508_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette]
509
510
511def _normalize_palette(
512 im: Image.Image, palette: _Palette | None, info: dict[str, Any]
513) -> Image.Image:
514 """
515 Normalizes the palette for image.
516 - Sets the palette to the incoming palette, if provided.
517 - Ensures that there's a palette for L mode images
518 - Optimizes the palette if necessary/desired.
519
520 :param im: Image object
521 :param palette: bytes object containing the source palette, or ....
522 :param info: encoderinfo
523 :returns: Image object
524 """
525 source_palette = None
526 if palette:
527 # a bytes palette
528 if isinstance(palette, (bytes, bytearray, list)):
529 source_palette = bytearray(palette[:768])
530 if isinstance(palette, ImagePalette.ImagePalette):
531 source_palette = bytearray(palette.palette)
532
533 if im.mode == "P":
534 if not source_palette:
535 source_palette = im.im.getpalette("RGB")[:768]
536 else: # L-mode
537 if not source_palette:
538 source_palette = bytearray(i // 3 for i in range(768))
539 im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
540
541 used_palette_colors: list[int] | None
542 if palette:
543 used_palette_colors = []
544 assert source_palette is not None
545 for i in range(0, len(source_palette), 3):
546 source_color = tuple(source_palette[i : i + 3])
547 index = im.palette.colors.get(source_color)
548 if index in used_palette_colors:
549 index = None
550 used_palette_colors.append(index)
551 for i, index in enumerate(used_palette_colors):
552 if index is None:
553 for j in range(len(used_palette_colors)):
554 if j not in used_palette_colors:
555 used_palette_colors[i] = j
556 break
557 im = im.remap_palette(used_palette_colors)
558 else:
559 used_palette_colors = _get_optimize(im, info)
560 if used_palette_colors is not None:
561 im = im.remap_palette(used_palette_colors, source_palette)
562 if "transparency" in info:
563 try:
564 info["transparency"] = used_palette_colors.index(
565 info["transparency"]
566 )
567 except ValueError:
568 del info["transparency"]
569 return im
570
571 im.palette.palette = source_palette
572 return im
573
574
575def _write_single_frame(
576 im: Image.Image,
577 fp: IO[bytes],
578 palette: _Palette | None,
579) -> None:
580 im_out = _normalize_mode(im)
581 for k, v in im_out.info.items():
582 im.encoderinfo.setdefault(k, v)
583 im_out = _normalize_palette(im_out, palette, im.encoderinfo)
584
585 for s in _get_global_header(im_out, im.encoderinfo):
586 fp.write(s)
587
588 # local image header
589 flags = 0
590 if get_interlace(im):
591 flags = flags | 64
592 _write_local_header(fp, im, (0, 0), flags)
593
594 im_out.encoderconfig = (8, get_interlace(im))
595 ImageFile._save(im_out, fp, [("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])])
596
597 fp.write(b"\0") # end of image data
598
599
600def _getbbox(
601 base_im: Image.Image, im_frame: Image.Image
602) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
603 if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
604 im_frame = im_frame.convert("RGBA")
605 base_im = base_im.convert("RGBA")
606 delta = ImageChops.subtract_modulo(im_frame, base_im)
607 return delta, delta.getbbox(alpha_only=False)
608
609
610class _Frame(NamedTuple):
611 im: Image.Image
612 bbox: tuple[int, int, int, int] | None
613 encoderinfo: dict[str, Any]
614
615
616def _write_multiple_frames(
617 im: Image.Image, fp: IO[bytes], palette: _Palette | None
618) -> bool:
619 duration = im.encoderinfo.get("duration")
620 disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
621
622 im_frames: list[_Frame] = []
623 previous_im: Image.Image | None = None
624 frame_count = 0
625 background_im = None
626 for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
627 for im_frame in ImageSequence.Iterator(imSequence):
628 # a copy is required here since seek can still mutate the image
629 im_frame = _normalize_mode(im_frame.copy())
630 if frame_count == 0:
631 for k, v in im_frame.info.items():
632 if k == "transparency":
633 continue
634 im.encoderinfo.setdefault(k, v)
635
636 encoderinfo = im.encoderinfo.copy()
637 if "transparency" in im_frame.info:
638 encoderinfo.setdefault("transparency", im_frame.info["transparency"])
639 im_frame = _normalize_palette(im_frame, palette, encoderinfo)
640 if isinstance(duration, (list, tuple)):
641 encoderinfo["duration"] = duration[frame_count]
642 elif duration is None and "duration" in im_frame.info:
643 encoderinfo["duration"] = im_frame.info["duration"]
644 if isinstance(disposal, (list, tuple)):
645 encoderinfo["disposal"] = disposal[frame_count]
646 frame_count += 1
647
648 diff_frame = None
649 if im_frames and previous_im:
650 # delta frame
651 delta, bbox = _getbbox(previous_im, im_frame)
652 if not bbox:
653 # This frame is identical to the previous frame
654 if encoderinfo.get("duration"):
655 im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
656 continue
657 if im_frames[-1].encoderinfo.get("disposal") == 2:
658 if background_im is None:
659 color = im.encoderinfo.get(
660 "transparency", im.info.get("transparency", (0, 0, 0))
661 )
662 background = _get_background(im_frame, color)
663 background_im = Image.new("P", im_frame.size, background)
664 background_im.putpalette(im_frames[0].im.palette)
665 bbox = _getbbox(background_im, im_frame)[1]
666 elif encoderinfo.get("optimize") and im_frame.mode != "1":
667 if "transparency" not in encoderinfo:
668 try:
669 encoderinfo["transparency"] = (
670 im_frame.palette._new_color_index(im_frame)
671 )
672 except ValueError:
673 pass
674 if "transparency" in encoderinfo:
675 # When the delta is zero, fill the image with transparency
676 diff_frame = im_frame.copy()
677 fill = Image.new("P", delta.size, encoderinfo["transparency"])
678 if delta.mode == "RGBA":
679 r, g, b, a = delta.split()
680 mask = ImageMath.lambda_eval(
681 lambda args: args["convert"](
682 args["max"](
683 args["max"](
684 args["max"](args["r"], args["g"]), args["b"]
685 ),
686 args["a"],
687 )
688 * 255,
689 "1",
690 ),
691 r=r,
692 g=g,
693 b=b,
694 a=a,
695 )
696 else:
697 if delta.mode == "P":
698 # Convert to L without considering palette
699 delta_l = Image.new("L", delta.size)
700 delta_l.putdata(delta.getdata())
701 delta = delta_l
702 mask = ImageMath.lambda_eval(
703 lambda args: args["convert"](args["im"] * 255, "1"),
704 im=delta,
705 )
706 diff_frame.paste(fill, mask=ImageOps.invert(mask))
707 else:
708 bbox = None
709 previous_im = im_frame
710 im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo))
711
712 if len(im_frames) == 1:
713 if "duration" in im.encoderinfo:
714 # Since multiple frames will not be written, use the combined duration
715 im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"]
716 return False
717
718 for frame_data in im_frames:
719 im_frame = frame_data.im
720 if not frame_data.bbox:
721 # global header
722 for s in _get_global_header(im_frame, frame_data.encoderinfo):
723 fp.write(s)
724 offset = (0, 0)
725 else:
726 # compress difference
727 if not palette:
728 frame_data.encoderinfo["include_color_table"] = True
729
730 im_frame = im_frame.crop(frame_data.bbox)
731 offset = frame_data.bbox[:2]
732 _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo)
733 return True
734
735
736def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
737 _save(im, fp, filename, save_all=True)
738
739
740def _save(
741 im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
742) -> None:
743 # header
744 if "palette" in im.encoderinfo or "palette" in im.info:
745 palette = im.encoderinfo.get("palette", im.info.get("palette"))
746 else:
747 palette = None
748 im.encoderinfo.setdefault("optimize", True)
749
750 if not save_all or not _write_multiple_frames(im, fp, palette):
751 _write_single_frame(im, fp, palette)
752
753 fp.write(b";") # end of file
754
755 if hasattr(fp, "flush"):
756 fp.flush()
757
758
759def get_interlace(im: Image.Image) -> int:
760 interlace = im.encoderinfo.get("interlace", 1)
761
762 # workaround for @PIL153
763 if min(im.size) < 16:
764 interlace = 0
765
766 return interlace
767
768
769def _write_local_header(
770 fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
771) -> None:
772 try:
773 transparency = im.encoderinfo["transparency"]
774 except KeyError:
775 transparency = None
776
777 if "duration" in im.encoderinfo:
778 duration = int(im.encoderinfo["duration"] / 10)
779 else:
780 duration = 0
781
782 disposal = int(im.encoderinfo.get("disposal", 0))
783
784 if transparency is not None or duration != 0 or disposal:
785 packed_flag = 1 if transparency is not None else 0
786 packed_flag |= disposal << 2
787
788 fp.write(
789 b"!"
790 + o8(249) # extension intro
791 + o8(4) # length
792 + o8(packed_flag) # packed fields
793 + o16(duration) # duration
794 + o8(transparency or 0) # transparency index
795 + o8(0)
796 )
797
798 include_color_table = im.encoderinfo.get("include_color_table")
799 if include_color_table:
800 palette_bytes = _get_palette_bytes(im)
801 color_table_size = _get_color_table_size(palette_bytes)
802 if color_table_size:
803 flags = flags | 128 # local color table flag
804 flags = flags | color_table_size
805
806 fp.write(
807 b","
808 + o16(offset[0]) # offset
809 + o16(offset[1])
810 + o16(im.size[0]) # size
811 + o16(im.size[1])
812 + o8(flags) # flags
813 )
814 if include_color_table and color_table_size:
815 fp.write(_get_header_palette(palette_bytes))
816 fp.write(o8(8)) # bits
817
818
819def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
820 # Unused by default.
821 # To use, uncomment the register_save call at the end of the file.
822 #
823 # If you need real GIF compression and/or RGB quantization, you
824 # can use the external NETPBM/PBMPLUS utilities. See comments
825 # below for information on how to enable this.
826 tempfile = im._dump()
827
828 try:
829 with open(filename, "wb") as f:
830 if im.mode != "RGB":
831 subprocess.check_call(
832 ["ppmtogif", tempfile], stdout=f, stderr=subprocess.DEVNULL
833 )
834 else:
835 # Pipe ppmquant output into ppmtogif
836 # "ppmquant 256 %s | ppmtogif > %s" % (tempfile, filename)
837 quant_cmd = ["ppmquant", "256", tempfile]
838 togif_cmd = ["ppmtogif"]
839 quant_proc = subprocess.Popen(
840 quant_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
841 )
842 togif_proc = subprocess.Popen(
843 togif_cmd,
844 stdin=quant_proc.stdout,
845 stdout=f,
846 stderr=subprocess.DEVNULL,
847 )
848
849 # Allow ppmquant to receive SIGPIPE if ppmtogif exits
850 assert quant_proc.stdout is not None
851 quant_proc.stdout.close()
852
853 retcode = quant_proc.wait()
854 if retcode:
855 raise subprocess.CalledProcessError(retcode, quant_cmd)
856
857 retcode = togif_proc.wait()
858 if retcode:
859 raise subprocess.CalledProcessError(retcode, togif_cmd)
860 finally:
861 try:
862 os.unlink(tempfile)
863 except OSError:
864 pass
865
866
867# Force optimization so that we can test performance against
868# cases where it took lots of memory and time previously.
869_FORCE_OPTIMIZE = False
870
871
872def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
873 """
874 Palette optimization is a potentially expensive operation.
875
876 This function determines if the palette should be optimized using
877 some heuristics, then returns the list of palette entries in use.
878
879 :param im: Image object
880 :param info: encoderinfo
881 :returns: list of indexes of palette entries in use, or None
882 """
883 if im.mode in ("P", "L") and info and info.get("optimize"):
884 # Potentially expensive operation.
885
886 # The palette saves 3 bytes per color not used, but palette
887 # lengths are restricted to 3*(2**N) bytes. Max saving would
888 # be 768 -> 6 bytes if we went all the way down to 2 colors.
889 # * If we're over 128 colors, we can't save any space.
890 # * If there aren't any holes, it's not worth collapsing.
891 # * If we have a 'large' image, the palette is in the noise.
892
893 # create the new palette if not every color is used
894 optimise = _FORCE_OPTIMIZE or im.mode == "L"
895 if optimise or im.width * im.height < 512 * 512:
896 # check which colors are used
897 used_palette_colors = []
898 for i, count in enumerate(im.histogram()):
899 if count:
900 used_palette_colors.append(i)
901
902 if optimise or max(used_palette_colors) >= len(used_palette_colors):
903 return used_palette_colors
904
905 num_palette_colors = len(im.palette.palette) // Image.getmodebands(
906 im.palette.mode
907 )
908 current_palette_size = 1 << (num_palette_colors - 1).bit_length()
909 if (
910 # check that the palette would become smaller when saved
911 len(used_palette_colors) <= current_palette_size // 2
912 # check that the palette is not already the smallest possible size
913 and current_palette_size > 2
914 ):
915 return used_palette_colors
916 return None
917
918
919def _get_color_table_size(palette_bytes: bytes) -> int:
920 # calculate the palette size for the header
921 if not palette_bytes:
922 return 0
923 elif len(palette_bytes) < 9:
924 return 1
925 else:
926 return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1
927
928
929def _get_header_palette(palette_bytes: bytes) -> bytes:
930 """
931 Returns the palette, null padded to the next power of 2 (*3) bytes
932 suitable for direct inclusion in the GIF header
933
934 :param palette_bytes: Unpadded palette bytes, in RGBRGB form
935 :returns: Null padded palette
936 """
937 color_table_size = _get_color_table_size(palette_bytes)
938
939 # add the missing amount of bytes
940 # the palette has to be 2<<n in size
941 actual_target_size_diff = (2 << color_table_size) - len(palette_bytes) // 3
942 if actual_target_size_diff > 0:
943 palette_bytes += o8(0) * 3 * actual_target_size_diff
944 return palette_bytes
945
946
947def _get_palette_bytes(im: Image.Image) -> bytes:
948 """
949 Gets the palette for inclusion in the gif header
950
951 :param im: Image object
952 :returns: Bytes, len<=768 suitable for inclusion in gif header
953 """
954 return im.palette.palette if im.palette else b""
955
956
957def _get_background(
958 im: Image.Image,
959 info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
960) -> int:
961 background = 0
962 if info_background:
963 if isinstance(info_background, tuple):
964 # WebPImagePlugin stores an RGBA value in info["background"]
965 # So it must be converted to the same format as GifImagePlugin's
966 # info["background"] - a global color table index
967 try:
968 background = im.palette.getcolor(info_background, im)
969 except ValueError as e:
970 if str(e) not in (
971 # If all 256 colors are in use,
972 # then there is no need for the background color
973 "cannot allocate more than 256 colors",
974 # Ignore non-opaque WebP background
975 "cannot add non-opaque RGBA color to RGB palette",
976 ):
977 raise
978 else:
979 background = info_background
980 return background
981
982
983def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
984 """Return a list of strings representing a GIF header"""
985
986 # Header Block
987 # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
988
989 version = b"87a"
990 if im.info.get("version") == b"89a" or (
991 info
992 and (
993 "transparency" in info
994 or info.get("loop") is not None
995 or info.get("duration")
996 or info.get("comment")
997 )
998 ):
999 version = b"89a"
1000
1001 background = _get_background(im, info.get("background"))
1002
1003 palette_bytes = _get_palette_bytes(im)
1004 color_table_size = _get_color_table_size(palette_bytes)
1005
1006 header = [
1007 b"GIF" # signature
1008 + version # version
1009 + o16(im.size[0]) # canvas width
1010 + o16(im.size[1]), # canvas height
1011 # Logical Screen Descriptor
1012 # size of global color table + global color table flag
1013 o8(color_table_size + 128), # packed fields
1014 # background + reserved/aspect
1015 o8(background) + o8(0),
1016 # Global Color Table
1017 _get_header_palette(palette_bytes),
1018 ]
1019 if info.get("loop") is not None:
1020 header.append(
1021 b"!"
1022 + o8(255) # extension intro
1023 + o8(11)
1024 + b"NETSCAPE2.0"
1025 + o8(3)
1026 + o8(1)
1027 + o16(info["loop"]) # number of loops
1028 + o8(0)
1029 )
1030 if info.get("comment"):
1031 comment_block = b"!" + o8(254) # extension intro
1032
1033 comment = info["comment"]
1034 if isinstance(comment, str):
1035 comment = comment.encode()
1036 for i in range(0, len(comment), 255):
1037 subblock = comment[i : i + 255]
1038 comment_block += o8(len(subblock)) + subblock
1039
1040 comment_block += o8(0)
1041 header.append(comment_block)
1042 return header
1043
1044
1045def _write_frame_data(
1046 fp: IO[bytes],
1047 im_frame: Image.Image,
1048 offset: tuple[int, int],
1049 params: dict[str, Any],
1050) -> None:
1051 try:
1052 im_frame.encoderinfo = params
1053
1054 # local image header
1055 _write_local_header(fp, im_frame, offset, 0)
1056
1057 ImageFile._save(
1058 im_frame, fp, [("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])]
1059 )
1060
1061 fp.write(b"\0") # end of image data
1062 finally:
1063 del im_frame.encoderinfo
1064
1065
1066# --------------------------------------------------------------------
1067# Legacy GIF utilities
1068
1069
1070def getheader(
1071 im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None
1072) -> tuple[list[bytes], list[int] | None]:
1073 """
1074 Legacy Method to get Gif data from image.
1075
1076 Warning:: May modify image data.
1077
1078 :param im: Image object
1079 :param palette: bytes object containing the source palette, or ....
1080 :param info: encoderinfo
1081 :returns: tuple of(list of header items, optimized palette)
1082
1083 """
1084 if info is None:
1085 info = {}
1086
1087 used_palette_colors = _get_optimize(im, info)
1088
1089 if "background" not in info and "background" in im.info:
1090 info["background"] = im.info["background"]
1091
1092 im_mod = _normalize_palette(im, palette, info)
1093 im.palette = im_mod.palette
1094 im.im = im_mod.im
1095 header = _get_global_header(im, info)
1096
1097 return header, used_palette_colors
1098
1099
1100def getdata(
1101 im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
1102) -> list[bytes]:
1103 """
1104 Legacy Method
1105
1106 Return a list of strings representing this image.
1107 The first string is a local image header, the rest contains
1108 encoded image data.
1109
1110 To specify duration, add the time in milliseconds,
1111 e.g. ``getdata(im_frame, duration=1000)``
1112
1113 :param im: Image object
1114 :param offset: Tuple of (x, y) pixels. Defaults to (0, 0)
1115 :param \\**params: e.g. duration or other encoder info parameters
1116 :returns: List of bytes containing GIF encoded frame data
1117
1118 """
1119 from io import BytesIO
1120
1121 class Collector(BytesIO):
1122 data = []
1123
1124 if sys.version_info >= (3, 12):
1125 from collections.abc import Buffer
1126
1127 def write(self, data: Buffer) -> int:
1128 self.data.append(data)
1129 return len(data)
1130
1131 else:
1132
1133 def write(self, data: Any) -> int:
1134 self.data.append(data)
1135 return len(data)
1136
1137 im.load() # make sure raster data is available
1138
1139 fp = Collector()
1140
1141 _write_frame_data(fp, im, offset, params)
1142
1143 return fp.data
1144
1145
1146# --------------------------------------------------------------------
1147# Registry
1148
1149Image.register_open(GifImageFile.format, GifImageFile, _accept)
1150Image.register_save(GifImageFile.format, _save)
1151Image.register_save_all(GifImageFile.format, _save_all)
1152Image.register_extension(GifImageFile.format, ".gif")
1153Image.register_mime(GifImageFile.format, "image/gif")
1154
1155#
1156# Uncomment the following line if you wish to use NETPBM/PBMPLUS
1157# instead of the built-in "uncompressed" GIF encoder
1158
1159# Image.register_save(GifImageFile.format, _save_netpbm)