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