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