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 self.tile = []
107 flags = s[10]
108 bits = (flags & 7) + 1
109
110 if flags & 128:
111 # get global palette
112 self.info["background"] = s[11]
113 # check if palette contains colour indices
114 p = self.fp.read(3 << bits)
115 if self._is_palette_needed(p):
116 p = ImagePalette.raw("RGB", p)
117 self.global_palette = self.palette = p
118
119 self._fp = self.fp # FIXME: hack
120 self.__rewind = self.fp.tell()
121 self._n_frames: int | None = None
122 self._seek(0) # get ready to read first frame
123
124 @property
125 def n_frames(self) -> int:
126 if self._n_frames is None:
127 current = self.tell()
128 try:
129 while True:
130 self._seek(self.tell() + 1, False)
131 except EOFError:
132 self._n_frames = self.tell() + 1
133 self.seek(current)
134 return self._n_frames
135
136 @cached_property
137 def is_animated(self) -> bool:
138 if self._n_frames is not None:
139 return self._n_frames != 1
140
141 current = self.tell()
142 if current:
143 return True
144
145 try:
146 self._seek(1, False)
147 is_animated = True
148 except EOFError:
149 is_animated = False
150
151 self.seek(current)
152 return is_animated
153
154 def seek(self, frame: int) -> None:
155 if not self._seek_check(frame):
156 return
157 if frame < self.__frame:
158 self._im = None
159 self._seek(0)
160
161 last_frame = self.__frame
162 for f in range(self.__frame + 1, frame + 1):
163 try:
164 self._seek(f)
165 except EOFError as e:
166 self.seek(last_frame)
167 msg = "no more images in GIF file"
168 raise EOFError(msg) from e
169
170 def _seek(self, frame: int, update_image: bool = True) -> None:
171 if frame == 0:
172 # rewind
173 self.__offset = 0
174 self.dispose: _imaging.ImagingCore | None = None
175 self.__frame = -1
176 self._fp.seek(self.__rewind)
177 self.disposal_method = 0
178 if "comment" in self.info:
179 del self.info["comment"]
180 else:
181 # ensure that the previous frame was loaded
182 if self.tile and update_image:
183 self.load()
184
185 if frame != self.__frame + 1:
186 msg = f"cannot seek to frame {frame}"
187 raise ValueError(msg)
188
189 self.fp = self._fp
190 if self.__offset:
191 # backup to last frame
192 self.fp.seek(self.__offset)
193 while self.data():
194 pass
195 self.__offset = 0
196
197 s = self.fp.read(1)
198 if not s or s == b";":
199 msg = "no more images in GIF file"
200 raise EOFError(msg)
201
202 palette: ImagePalette.ImagePalette | Literal[False] | None = None
203
204 info: dict[str, Any] = {}
205 frame_transparency = None
206 interlace = None
207 frame_dispose_extent = None
208 while True:
209 if not s:
210 s = self.fp.read(1)
211 if not s or s == b";":
212 break
213
214 elif s == b"!":
215 #
216 # extensions
217 #
218 s = self.fp.read(1)
219 block = self.data()
220 if s[0] == 249 and block is not None:
221 #
222 # graphic control extension
223 #
224 flags = block[0]
225 if flags & 1:
226 frame_transparency = block[3]
227 info["duration"] = i16(block, 1) * 10
228
229 # disposal method - find the value of bits 4 - 6
230 dispose_bits = 0b00011100 & flags
231 dispose_bits = dispose_bits >> 2
232 if dispose_bits:
233 # only set the dispose if it is not
234 # unspecified. I'm not sure if this is
235 # correct, but it seems to prevent the last
236 # frame from looking odd for some animations
237 self.disposal_method = dispose_bits
238 elif s[0] == 254:
239 #
240 # comment extension
241 #
242 comment = b""
243
244 # Read this comment block
245 while block:
246 comment += block
247 block = self.data()
248
249 if "comment" in info:
250 # If multiple comment blocks in frame, separate with \n
251 info["comment"] += b"\n" + comment
252 else:
253 info["comment"] = comment
254 s = None
255 continue
256 elif s[0] == 255 and frame == 0 and block is not None:
257 #
258 # application extension
259 #
260 info["extension"] = block, self.fp.tell()
261 if block[:11] == b"NETSCAPE2.0":
262 block = self.data()
263 if block and len(block) >= 3 and block[0] == 1:
264 self.info["loop"] = i16(block, 1)
265 while self.data():
266 pass
267
268 elif s == b",":
269 #
270 # local image
271 #
272 s = self.fp.read(9)
273
274 # extent
275 x0, y0 = i16(s, 0), i16(s, 2)
276 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
277 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
278 self._size = max(x1, self.size[0]), max(y1, self.size[1])
279 Image._decompression_bomb_check(self._size)
280 frame_dispose_extent = x0, y0, x1, y1
281 flags = s[8]
282
283 interlace = (flags & 64) != 0
284
285 if flags & 128:
286 bits = (flags & 7) + 1
287 p = self.fp.read(3 << bits)
288 if self._is_palette_needed(p):
289 palette = ImagePalette.raw("RGB", p)
290 else:
291 palette = False
292
293 # image data
294 bits = self.fp.read(1)[0]
295 self.__offset = self.fp.tell()
296 break
297 s = None
298
299 if interlace is None:
300 msg = "image not found in GIF frame"
301 raise EOFError(msg)
302
303 self.__frame = frame
304 if not update_image:
305 return
306
307 self.tile = []
308
309 if self.dispose:
310 self.im.paste(self.dispose, self.dispose_extent)
311
312 self._frame_palette = palette if palette is not None else self.global_palette
313 self._frame_transparency = frame_transparency
314 if frame == 0:
315 if self._frame_palette:
316 if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
317 self._mode = "RGBA" if frame_transparency is not None else "RGB"
318 else:
319 self._mode = "P"
320 else:
321 self._mode = "L"
322
323 if palette:
324 self.palette = palette
325 elif self.global_palette:
326 from copy import copy
327
328 self.palette = copy(self.global_palette)
329 else:
330 self.palette = None
331 else:
332 if self.mode == "P":
333 if (
334 LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
335 or palette
336 ):
337 if "transparency" in self.info:
338 self.im.putpalettealpha(self.info["transparency"], 0)
339 self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
340 self._mode = "RGBA"
341 del self.info["transparency"]
342 else:
343 self._mode = "RGB"
344 self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
345
346 def _rgb(color: int) -> tuple[int, int, int]:
347 if self._frame_palette:
348 if color * 3 + 3 > len(self._frame_palette.palette):
349 color = 0
350 return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
351 else:
352 return (color, color, color)
353
354 self.dispose = None
355 self.dispose_extent = frame_dispose_extent
356 if self.dispose_extent and self.disposal_method >= 2:
357 try:
358 if self.disposal_method == 2:
359 # replace with background colour
360
361 # only dispose the extent in this frame
362 x0, y0, x1, y1 = self.dispose_extent
363 dispose_size = (x1 - x0, y1 - y0)
364
365 Image._decompression_bomb_check(dispose_size)
366
367 # by convention, attempt to use transparency first
368 dispose_mode = "P"
369 color = self.info.get("transparency", frame_transparency)
370 if color is not None:
371 if self.mode in ("RGB", "RGBA"):
372 dispose_mode = "RGBA"
373 color = _rgb(color) + (0,)
374 else:
375 color = self.info.get("background", 0)
376 if self.mode in ("RGB", "RGBA"):
377 dispose_mode = "RGB"
378 color = _rgb(color)
379 self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
380 else:
381 # replace with previous contents
382 if self._im is not None:
383 # only dispose the extent in this frame
384 self.dispose = self._crop(self.im, self.dispose_extent)
385 elif frame_transparency is not None:
386 x0, y0, x1, y1 = self.dispose_extent
387 dispose_size = (x1 - x0, y1 - y0)
388
389 Image._decompression_bomb_check(dispose_size)
390 dispose_mode = "P"
391 color = frame_transparency
392 if self.mode in ("RGB", "RGBA"):
393 dispose_mode = "RGBA"
394 color = _rgb(frame_transparency) + (0,)
395 self.dispose = Image.core.fill(
396 dispose_mode, dispose_size, color
397 )
398 except AttributeError:
399 pass
400
401 if interlace is not None:
402 transparency = -1
403 if frame_transparency is not None:
404 if frame == 0:
405 if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS:
406 self.info["transparency"] = frame_transparency
407 elif self.mode not in ("RGB", "RGBA"):
408 transparency = frame_transparency
409 self.tile = [
410 ImageFile._Tile(
411 "gif",
412 (x0, y0, x1, y1),
413 self.__offset,
414 (bits, interlace, transparency),
415 )
416 ]
417
418 if info.get("comment"):
419 self.info["comment"] = info["comment"]
420 for k in ["duration", "extension"]:
421 if k in info:
422 self.info[k] = info[k]
423 elif k in self.info:
424 del self.info[k]
425
426 def load_prepare(self) -> None:
427 temp_mode = "P" if self._frame_palette else "L"
428 self._prev_im = None
429 if self.__frame == 0:
430 if self._frame_transparency is not None:
431 self.im = Image.core.fill(
432 temp_mode, self.size, self._frame_transparency
433 )
434 elif self.mode in ("RGB", "RGBA"):
435 self._prev_im = self.im
436 if self._frame_palette:
437 self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
438 self.im.putpalette("RGB", *self._frame_palette.getdata())
439 else:
440 self._im = None
441 if not self._prev_im and self._im is not None and self.size != self.im.size:
442 expanded_im = Image.core.fill(self.im.mode, self.size)
443 if self._frame_palette:
444 expanded_im.putpalette("RGB", *self._frame_palette.getdata())
445 expanded_im.paste(self.im, (0, 0) + self.im.size)
446
447 self.im = expanded_im
448 self._mode = temp_mode
449 self._frame_palette = None
450
451 super().load_prepare()
452
453 def load_end(self) -> None:
454 if self.__frame == 0:
455 if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
456 if self._frame_transparency is not None:
457 self.im.putpalettealpha(self._frame_transparency, 0)
458 self._mode = "RGBA"
459 else:
460 self._mode = "RGB"
461 self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG)
462 return
463 if not self._prev_im:
464 return
465 if self.size != self._prev_im.size:
466 if self._frame_transparency is not None:
467 expanded_im = Image.core.fill("RGBA", self.size)
468 else:
469 expanded_im = Image.core.fill("P", self.size)
470 expanded_im.putpalette("RGB", "RGB", self.im.getpalette())
471 expanded_im = expanded_im.convert("RGB")
472 expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size)
473
474 self._prev_im = expanded_im
475 assert self._prev_im is not None
476 if self._frame_transparency is not None:
477 self.im.putpalettealpha(self._frame_transparency, 0)
478 frame_im = self.im.convert("RGBA")
479 else:
480 frame_im = self.im.convert("RGB")
481
482 assert self.dispose_extent is not None
483 frame_im = self._crop(frame_im, self.dispose_extent)
484
485 self.im = self._prev_im
486 self._mode = self.im.mode
487 if frame_im.mode == "RGBA":
488 self.im.paste(frame_im, self.dispose_extent, frame_im)
489 else:
490 self.im.paste(frame_im, self.dispose_extent)
491
492 def tell(self) -> int:
493 return self.__frame
494
495
496# --------------------------------------------------------------------
497# Write GIF files
498
499
500RAWMODE = {"1": "L", "L": "L", "P": "P"}
501
502
503def _normalize_mode(im: Image.Image) -> Image.Image:
504 """
505 Takes an image (or frame), returns an image in a mode that is appropriate
506 for saving in a Gif.
507
508 It may return the original image, or it may return an image converted to
509 palette or 'L' mode.
510
511 :param im: Image object
512 :returns: Image object
513 """
514 if im.mode in RAWMODE:
515 im.load()
516 return im
517 if Image.getmodebase(im.mode) == "RGB":
518 im = im.convert("P", palette=Image.Palette.ADAPTIVE)
519 assert im.palette is not None
520 if im.palette.mode == "RGBA":
521 for rgba in im.palette.colors:
522 if rgba[3] == 0:
523 im.info["transparency"] = im.palette.colors[rgba]
524 break
525 return im
526 return im.convert("L")
527
528
529_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette]
530
531
532def _normalize_palette(
533 im: Image.Image, palette: _Palette | None, info: dict[str, Any]
534) -> Image.Image:
535 """
536 Normalizes the palette for image.
537 - Sets the palette to the incoming palette, if provided.
538 - Ensures that there's a palette for L mode images
539 - Optimizes the palette if necessary/desired.
540
541 :param im: Image object
542 :param palette: bytes object containing the source palette, or ....
543 :param info: encoderinfo
544 :returns: Image object
545 """
546 source_palette = None
547 if palette:
548 # a bytes palette
549 if isinstance(palette, (bytes, bytearray, list)):
550 source_palette = bytearray(palette[:768])
551 if isinstance(palette, ImagePalette.ImagePalette):
552 source_palette = bytearray(palette.palette)
553
554 if im.mode == "P":
555 if not source_palette:
556 im_palette = im.getpalette(None)
557 assert im_palette is not None
558 source_palette = bytearray(im_palette)
559 else: # L-mode
560 if not source_palette:
561 source_palette = bytearray(i // 3 for i in range(768))
562 im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
563 assert source_palette is not None
564
565 if palette:
566 used_palette_colors: list[int | None] = []
567 assert im.palette is not None
568 for i in range(0, len(source_palette), 3):
569 source_color = tuple(source_palette[i : i + 3])
570 index = im.palette.colors.get(source_color)
571 if index in used_palette_colors:
572 index = None
573 used_palette_colors.append(index)
574 for i, index in enumerate(used_palette_colors):
575 if index is None:
576 for j in range(len(used_palette_colors)):
577 if j not in used_palette_colors:
578 used_palette_colors[i] = j
579 break
580 dest_map: list[int] = []
581 for index in used_palette_colors:
582 assert index is not None
583 dest_map.append(index)
584 im = im.remap_palette(dest_map)
585 else:
586 optimized_palette_colors = _get_optimize(im, info)
587 if optimized_palette_colors is not None:
588 im = im.remap_palette(optimized_palette_colors, source_palette)
589 if "transparency" in info:
590 try:
591 info["transparency"] = optimized_palette_colors.index(
592 info["transparency"]
593 )
594 except ValueError:
595 del info["transparency"]
596 return im
597
598 assert im.palette is not None
599 im.palette.palette = source_palette
600 return im
601
602
603def _write_single_frame(
604 im: Image.Image,
605 fp: IO[bytes],
606 palette: _Palette | None,
607) -> None:
608 im_out = _normalize_mode(im)
609 for k, v in im_out.info.items():
610 if isinstance(k, str):
611 im.encoderinfo.setdefault(k, v)
612 im_out = _normalize_palette(im_out, palette, im.encoderinfo)
613
614 for s in _get_global_header(im_out, im.encoderinfo):
615 fp.write(s)
616
617 # local image header
618 flags = 0
619 if get_interlace(im):
620 flags = flags | 64
621 _write_local_header(fp, im, (0, 0), flags)
622
623 im_out.encoderconfig = (8, get_interlace(im))
624 ImageFile._save(
625 im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]
626 )
627
628 fp.write(b"\0") # end of image data
629
630
631def _getbbox(
632 base_im: Image.Image, im_frame: Image.Image
633) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
634 palette_bytes = [
635 bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame)
636 ]
637 if palette_bytes[0] != palette_bytes[1]:
638 im_frame = im_frame.convert("RGBA")
639 base_im = base_im.convert("RGBA")
640 delta = ImageChops.subtract_modulo(im_frame, base_im)
641 return delta, delta.getbbox(alpha_only=False)
642
643
644class _Frame(NamedTuple):
645 im: Image.Image
646 bbox: tuple[int, int, int, int] | None
647 encoderinfo: dict[str, Any]
648
649
650def _write_multiple_frames(
651 im: Image.Image, fp: IO[bytes], palette: _Palette | None
652) -> bool:
653 duration = im.encoderinfo.get("duration")
654 disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
655
656 im_frames: list[_Frame] = []
657 previous_im: Image.Image | None = None
658 frame_count = 0
659 background_im = None
660 for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
661 for im_frame in ImageSequence.Iterator(imSequence):
662 # a copy is required here since seek can still mutate the image
663 im_frame = _normalize_mode(im_frame.copy())
664 if frame_count == 0:
665 for k, v in im_frame.info.items():
666 if k == "transparency":
667 continue
668 if isinstance(k, str):
669 im.encoderinfo.setdefault(k, v)
670
671 encoderinfo = im.encoderinfo.copy()
672 if "transparency" in im_frame.info:
673 encoderinfo.setdefault("transparency", im_frame.info["transparency"])
674 im_frame = _normalize_palette(im_frame, palette, encoderinfo)
675 if isinstance(duration, (list, tuple)):
676 encoderinfo["duration"] = duration[frame_count]
677 elif duration is None and "duration" in im_frame.info:
678 encoderinfo["duration"] = im_frame.info["duration"]
679 if isinstance(disposal, (list, tuple)):
680 encoderinfo["disposal"] = disposal[frame_count]
681 frame_count += 1
682
683 diff_frame = None
684 if im_frames and previous_im:
685 # delta frame
686 delta, bbox = _getbbox(previous_im, im_frame)
687 if not bbox:
688 # This frame is identical to the previous frame
689 if encoderinfo.get("duration"):
690 im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
691 continue
692 if im_frames[-1].encoderinfo.get("disposal") == 2:
693 if background_im is None:
694 color = im.encoderinfo.get(
695 "transparency", im.info.get("transparency", (0, 0, 0))
696 )
697 background = _get_background(im_frame, color)
698 background_im = Image.new("P", im_frame.size, background)
699 assert im_frames[0].im.palette is not None
700 background_im.putpalette(im_frames[0].im.palette)
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)