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