aboutsummaryrefslogtreecommitdiff
path: root/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py')
-rw-r--r--.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py1197
1 files changed, 1197 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py b/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py
new file mode 100644
index 00000000..47022d58
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py
@@ -0,0 +1,1197 @@
+#
+# The Python Imaging Library.
+# $Id$
+#
+# GIF file handling
+#
+# History:
+# 1995-09-01 fl Created
+# 1996-12-14 fl Added interlace support
+# 1996-12-30 fl Added animation support
+# 1997-01-05 fl Added write support, fixed local colour map bug
+# 1997-02-23 fl Make sure to load raster data in getdata()
+# 1997-07-05 fl Support external decoder (0.4)
+# 1998-07-09 fl Handle all modes when saving (0.5)
+# 1998-07-15 fl Renamed offset attribute to avoid name clash
+# 2001-04-16 fl Added rewind support (seek to frame 0) (0.6)
+# 2001-04-17 fl Added palette optimization (0.7)
+# 2002-06-06 fl Added transparency support for save (0.8)
+# 2004-02-24 fl Disable interlacing for small images
+#
+# Copyright (c) 1997-2004 by Secret Labs AB
+# Copyright (c) 1995-2004 by Fredrik Lundh
+#
+# See the README file for information on usage and redistribution.
+#
+from __future__ import annotations
+
+import itertools
+import math
+import os
+import subprocess
+from enum import IntEnum
+from functools import cached_property
+from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union
+
+from . import (
+ Image,
+ ImageChops,
+ ImageFile,
+ ImageMath,
+ ImageOps,
+ ImagePalette,
+ ImageSequence,
+)
+from ._binary import i16le as i16
+from ._binary import o8
+from ._binary import o16le as o16
+
+if TYPE_CHECKING:
+ from . import _imaging
+ from ._typing import Buffer
+
+
+class LoadingStrategy(IntEnum):
+ """.. versionadded:: 9.1.0"""
+
+ RGB_AFTER_FIRST = 0
+ RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1
+ RGB_ALWAYS = 2
+
+
+#: .. versionadded:: 9.1.0
+LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST
+
+# --------------------------------------------------------------------
+# Identify/read GIF files
+
+
+def _accept(prefix: bytes) -> bool:
+ return prefix[:6] in [b"GIF87a", b"GIF89a"]
+
+
+##
+# Image plugin for GIF images. This plugin supports both GIF87 and
+# GIF89 images.
+
+
+class GifImageFile(ImageFile.ImageFile):
+ format = "GIF"
+ format_description = "Compuserve GIF"
+ _close_exclusive_fp_after_loading = False
+
+ global_palette = None
+
+ def data(self) -> bytes | None:
+ s = self.fp.read(1)
+ if s and s[0]:
+ return self.fp.read(s[0])
+ return None
+
+ def _is_palette_needed(self, p: bytes) -> bool:
+ for i in range(0, len(p), 3):
+ if not (i // 3 == p[i] == p[i + 1] == p[i + 2]):
+ return True
+ return False
+
+ def _open(self) -> None:
+ # Screen
+ s = self.fp.read(13)
+ if not _accept(s):
+ msg = "not a GIF file"
+ raise SyntaxError(msg)
+
+ self.info["version"] = s[:6]
+ self._size = i16(s, 6), i16(s, 8)
+ flags = s[10]
+ bits = (flags & 7) + 1
+
+ if flags & 128:
+ # get global palette
+ self.info["background"] = s[11]
+ # check if palette contains colour indices
+ p = self.fp.read(3 << bits)
+ if self._is_palette_needed(p):
+ p = ImagePalette.raw("RGB", p)
+ self.global_palette = self.palette = p
+
+ self._fp = self.fp # FIXME: hack
+ self.__rewind = self.fp.tell()
+ self._n_frames: int | None = None
+ self._seek(0) # get ready to read first frame
+
+ @property
+ def n_frames(self) -> int:
+ if self._n_frames is None:
+ current = self.tell()
+ try:
+ while True:
+ self._seek(self.tell() + 1, False)
+ except EOFError:
+ self._n_frames = self.tell() + 1
+ self.seek(current)
+ return self._n_frames
+
+ @cached_property
+ def is_animated(self) -> bool:
+ if self._n_frames is not None:
+ return self._n_frames != 1
+
+ current = self.tell()
+ if current:
+ return True
+
+ try:
+ self._seek(1, False)
+ is_animated = True
+ except EOFError:
+ is_animated = False
+
+ self.seek(current)
+ return is_animated
+
+ def seek(self, frame: int) -> None:
+ if not self._seek_check(frame):
+ return
+ if frame < self.__frame:
+ self._im = None
+ self._seek(0)
+
+ last_frame = self.__frame
+ for f in range(self.__frame + 1, frame + 1):
+ try:
+ self._seek(f)
+ except EOFError as e:
+ self.seek(last_frame)
+ msg = "no more images in GIF file"
+ raise EOFError(msg) from e
+
+ def _seek(self, frame: int, update_image: bool = True) -> None:
+ if frame == 0:
+ # rewind
+ self.__offset = 0
+ self.dispose: _imaging.ImagingCore | None = None
+ self.__frame = -1
+ self._fp.seek(self.__rewind)
+ self.disposal_method = 0
+ if "comment" in self.info:
+ del self.info["comment"]
+ else:
+ # ensure that the previous frame was loaded
+ if self.tile and update_image:
+ self.load()
+
+ if frame != self.__frame + 1:
+ msg = f"cannot seek to frame {frame}"
+ raise ValueError(msg)
+
+ self.fp = self._fp
+ if self.__offset:
+ # backup to last frame
+ self.fp.seek(self.__offset)
+ while self.data():
+ pass
+ self.__offset = 0
+
+ s = self.fp.read(1)
+ if not s or s == b";":
+ msg = "no more images in GIF file"
+ raise EOFError(msg)
+
+ palette: ImagePalette.ImagePalette | Literal[False] | None = None
+
+ info: dict[str, Any] = {}
+ frame_transparency = None
+ interlace = None
+ frame_dispose_extent = None
+ while True:
+ if not s:
+ s = self.fp.read(1)
+ if not s or s == b";":
+ break
+
+ elif s == b"!":
+ #
+ # extensions
+ #
+ s = self.fp.read(1)
+ block = self.data()
+ if s[0] == 249 and block is not None:
+ #
+ # graphic control extension
+ #
+ flags = block[0]
+ if flags & 1:
+ frame_transparency = block[3]
+ info["duration"] = i16(block, 1) * 10
+
+ # disposal method - find the value of bits 4 - 6
+ dispose_bits = 0b00011100 & flags
+ dispose_bits = dispose_bits >> 2
+ if dispose_bits:
+ # only set the dispose if it is not
+ # unspecified. I'm not sure if this is
+ # correct, but it seems to prevent the last
+ # frame from looking odd for some animations
+ self.disposal_method = dispose_bits
+ elif s[0] == 254:
+ #
+ # comment extension
+ #
+ comment = b""
+
+ # Read this comment block
+ while block:
+ comment += block
+ block = self.data()
+
+ if "comment" in info:
+ # If multiple comment blocks in frame, separate with \n
+ info["comment"] += b"\n" + comment
+ else:
+ info["comment"] = comment
+ s = None
+ continue
+ elif s[0] == 255 and frame == 0 and block is not None:
+ #
+ # application extension
+ #
+ info["extension"] = block, self.fp.tell()
+ if block[:11] == b"NETSCAPE2.0":
+ block = self.data()
+ if block and len(block) >= 3 and block[0] == 1:
+ self.info["loop"] = i16(block, 1)
+ while self.data():
+ pass
+
+ elif s == b",":
+ #
+ # local image
+ #
+ s = self.fp.read(9)
+
+ # extent
+ x0, y0 = i16(s, 0), i16(s, 2)
+ x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
+ if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
+ self._size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(self._size)
+ frame_dispose_extent = x0, y0, x1, y1
+ flags = s[8]
+
+ interlace = (flags & 64) != 0
+
+ if flags & 128:
+ bits = (flags & 7) + 1
+ p = self.fp.read(3 << bits)
+ if self._is_palette_needed(p):
+ palette = ImagePalette.raw("RGB", p)
+ else:
+ palette = False
+
+ # image data
+ bits = self.fp.read(1)[0]
+ self.__offset = self.fp.tell()
+ break
+ s = None
+
+ if interlace is None:
+ msg = "image not found in GIF frame"
+ raise EOFError(msg)
+
+ self.__frame = frame
+ if not update_image:
+ return
+
+ self.tile = []
+
+ if self.dispose:
+ self.im.paste(self.dispose, self.dispose_extent)
+
+ self._frame_palette = palette if palette is not None else self.global_palette
+ self._frame_transparency = frame_transparency
+ if frame == 0:
+ if self._frame_palette:
+ if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
+ self._mode = "RGBA" if frame_transparency is not None else "RGB"
+ else:
+ self._mode = "P"
+ else:
+ self._mode = "L"
+
+ if palette:
+ self.palette = palette
+ elif self.global_palette:
+ from copy import copy
+
+ self.palette = copy(self.global_palette)
+ else:
+ self.palette = None
+ else:
+ if self.mode == "P":
+ if (
+ LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
+ or palette
+ ):
+ if "transparency" in self.info:
+ self.im.putpalettealpha(self.info["transparency"], 0)
+ self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
+ self._mode = "RGBA"
+ del self.info["transparency"]
+ else:
+ self._mode = "RGB"
+ self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
+
+ def _rgb(color: int) -> tuple[int, int, int]:
+ if self._frame_palette:
+ if color * 3 + 3 > len(self._frame_palette.palette):
+ color = 0
+ return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
+ else:
+ return (color, color, color)
+
+ self.dispose = None
+ self.dispose_extent = frame_dispose_extent
+ if self.dispose_extent and self.disposal_method >= 2:
+ try:
+ if self.disposal_method == 2:
+ # replace with background colour
+
+ # only dispose the extent in this frame
+ x0, y0, x1, y1 = self.dispose_extent
+ dispose_size = (x1 - x0, y1 - y0)
+
+ Image._decompression_bomb_check(dispose_size)
+
+ # by convention, attempt to use transparency first
+ dispose_mode = "P"
+ color = self.info.get("transparency", frame_transparency)
+ if color is not None:
+ if self.mode in ("RGB", "RGBA"):
+ dispose_mode = "RGBA"
+ color = _rgb(color) + (0,)
+ else:
+ color = self.info.get("background", 0)
+ if self.mode in ("RGB", "RGBA"):
+ dispose_mode = "RGB"
+ color = _rgb(color)
+ self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
+ else:
+ # replace with previous contents
+ if self._im is not None:
+ # only dispose the extent in this frame
+ self.dispose = self._crop(self.im, self.dispose_extent)
+ elif frame_transparency is not None:
+ x0, y0, x1, y1 = self.dispose_extent
+ dispose_size = (x1 - x0, y1 - y0)
+
+ Image._decompression_bomb_check(dispose_size)
+ dispose_mode = "P"
+ color = frame_transparency
+ if self.mode in ("RGB", "RGBA"):
+ dispose_mode = "RGBA"
+ color = _rgb(frame_transparency) + (0,)
+ self.dispose = Image.core.fill(
+ dispose_mode, dispose_size, color
+ )
+ except AttributeError:
+ pass
+
+ if interlace is not None:
+ transparency = -1
+ if frame_transparency is not None:
+ if frame == 0:
+ if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS:
+ self.info["transparency"] = frame_transparency
+ elif self.mode not in ("RGB", "RGBA"):
+ transparency = frame_transparency
+ self.tile = [
+ ImageFile._Tile(
+ "gif",
+ (x0, y0, x1, y1),
+ self.__offset,
+ (bits, interlace, transparency),
+ )
+ ]
+
+ if info.get("comment"):
+ self.info["comment"] = info["comment"]
+ for k in ["duration", "extension"]:
+ if k in info:
+ self.info[k] = info[k]
+ elif k in self.info:
+ del self.info[k]
+
+ def load_prepare(self) -> None:
+ temp_mode = "P" if self._frame_palette else "L"
+ self._prev_im = None
+ if self.__frame == 0:
+ if self._frame_transparency is not None:
+ self.im = Image.core.fill(
+ temp_mode, self.size, self._frame_transparency
+ )
+ elif self.mode in ("RGB", "RGBA"):
+ self._prev_im = self.im
+ if self._frame_palette:
+ self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
+ self.im.putpalette("RGB", *self._frame_palette.getdata())
+ else:
+ self._im = None
+ if not self._prev_im and self._im is not None and self.size != self.im.size:
+ expanded_im = Image.core.fill(self.im.mode, self.size)
+ if self._frame_palette:
+ expanded_im.putpalette("RGB", *self._frame_palette.getdata())
+ expanded_im.paste(self.im, (0, 0) + self.im.size)
+
+ self.im = expanded_im
+ self._mode = temp_mode
+ self._frame_palette = None
+
+ super().load_prepare()
+
+ def load_end(self) -> None:
+ if self.__frame == 0:
+ if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
+ if self._frame_transparency is not None:
+ self.im.putpalettealpha(self._frame_transparency, 0)
+ self._mode = "RGBA"
+ else:
+ self._mode = "RGB"
+ self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG)
+ return
+ if not self._prev_im:
+ return
+ if self.size != self._prev_im.size:
+ if self._frame_transparency is not None:
+ expanded_im = Image.core.fill("RGBA", self.size)
+ else:
+ expanded_im = Image.core.fill("P", self.size)
+ expanded_im.putpalette("RGB", "RGB", self.im.getpalette())
+ expanded_im = expanded_im.convert("RGB")
+ expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size)
+
+ self._prev_im = expanded_im
+ assert self._prev_im is not None
+ if self._frame_transparency is not None:
+ self.im.putpalettealpha(self._frame_transparency, 0)
+ frame_im = self.im.convert("RGBA")
+ else:
+ frame_im = self.im.convert("RGB")
+
+ assert self.dispose_extent is not None
+ frame_im = self._crop(frame_im, self.dispose_extent)
+
+ self.im = self._prev_im
+ self._mode = self.im.mode
+ if frame_im.mode == "RGBA":
+ self.im.paste(frame_im, self.dispose_extent, frame_im)
+ else:
+ self.im.paste(frame_im, self.dispose_extent)
+
+ def tell(self) -> int:
+ return self.__frame
+
+
+# --------------------------------------------------------------------
+# Write GIF files
+
+
+RAWMODE = {"1": "L", "L": "L", "P": "P"}
+
+
+def _normalize_mode(im: Image.Image) -> Image.Image:
+ """
+ Takes an image (or frame), returns an image in a mode that is appropriate
+ for saving in a Gif.
+
+ It may return the original image, or it may return an image converted to
+ palette or 'L' mode.
+
+ :param im: Image object
+ :returns: Image object
+ """
+ if im.mode in RAWMODE:
+ im.load()
+ return im
+ if Image.getmodebase(im.mode) == "RGB":
+ im = im.convert("P", palette=Image.Palette.ADAPTIVE)
+ assert im.palette is not None
+ if im.palette.mode == "RGBA":
+ for rgba in im.palette.colors:
+ if rgba[3] == 0:
+ im.info["transparency"] = im.palette.colors[rgba]
+ break
+ return im
+ return im.convert("L")
+
+
+_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette]
+
+
+def _normalize_palette(
+ im: Image.Image, palette: _Palette | None, info: dict[str, Any]
+) -> Image.Image:
+ """
+ Normalizes the palette for image.
+ - Sets the palette to the incoming palette, if provided.
+ - Ensures that there's a palette for L mode images
+ - Optimizes the palette if necessary/desired.
+
+ :param im: Image object
+ :param palette: bytes object containing the source palette, or ....
+ :param info: encoderinfo
+ :returns: Image object
+ """
+ source_palette = None
+ if palette:
+ # a bytes palette
+ if isinstance(palette, (bytes, bytearray, list)):
+ source_palette = bytearray(palette[:768])
+ if isinstance(palette, ImagePalette.ImagePalette):
+ source_palette = bytearray(palette.palette)
+
+ if im.mode == "P":
+ if not source_palette:
+ im_palette = im.getpalette(None)
+ assert im_palette is not None
+ source_palette = bytearray(im_palette)
+ else: # L-mode
+ if not source_palette:
+ source_palette = bytearray(i // 3 for i in range(768))
+ im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
+ assert source_palette is not None
+
+ if palette:
+ used_palette_colors: list[int | None] = []
+ assert im.palette is not None
+ for i in range(0, len(source_palette), 3):
+ source_color = tuple(source_palette[i : i + 3])
+ index = im.palette.colors.get(source_color)
+ if index in used_palette_colors:
+ index = None
+ used_palette_colors.append(index)
+ for i, index in enumerate(used_palette_colors):
+ if index is None:
+ for j in range(len(used_palette_colors)):
+ if j not in used_palette_colors:
+ used_palette_colors[i] = j
+ break
+ dest_map: list[int] = []
+ for index in used_palette_colors:
+ assert index is not None
+ dest_map.append(index)
+ im = im.remap_palette(dest_map)
+ else:
+ optimized_palette_colors = _get_optimize(im, info)
+ if optimized_palette_colors is not None:
+ im = im.remap_palette(optimized_palette_colors, source_palette)
+ if "transparency" in info:
+ try:
+ info["transparency"] = optimized_palette_colors.index(
+ info["transparency"]
+ )
+ except ValueError:
+ del info["transparency"]
+ return im
+
+ assert im.palette is not None
+ im.palette.palette = source_palette
+ return im
+
+
+def _write_single_frame(
+ im: Image.Image,
+ fp: IO[bytes],
+ palette: _Palette | None,
+) -> None:
+ im_out = _normalize_mode(im)
+ for k, v in im_out.info.items():
+ if isinstance(k, str):
+ im.encoderinfo.setdefault(k, v)
+ im_out = _normalize_palette(im_out, palette, im.encoderinfo)
+
+ for s in _get_global_header(im_out, im.encoderinfo):
+ fp.write(s)
+
+ # local image header
+ flags = 0
+ if get_interlace(im):
+ flags = flags | 64
+ _write_local_header(fp, im, (0, 0), flags)
+
+ im_out.encoderconfig = (8, get_interlace(im))
+ ImageFile._save(
+ im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]
+ )
+
+ fp.write(b"\0") # end of image data
+
+
+def _getbbox(
+ base_im: Image.Image, im_frame: Image.Image
+) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
+ palette_bytes = [
+ bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame)
+ ]
+ if palette_bytes[0] != palette_bytes[1]:
+ im_frame = im_frame.convert("RGBA")
+ base_im = base_im.convert("RGBA")
+ delta = ImageChops.subtract_modulo(im_frame, base_im)
+ return delta, delta.getbbox(alpha_only=False)
+
+
+class _Frame(NamedTuple):
+ im: Image.Image
+ bbox: tuple[int, int, int, int] | None
+ encoderinfo: dict[str, Any]
+
+
+def _write_multiple_frames(
+ im: Image.Image, fp: IO[bytes], palette: _Palette | None
+) -> bool:
+ duration = im.encoderinfo.get("duration")
+ disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
+
+ im_frames: list[_Frame] = []
+ previous_im: Image.Image | None = None
+ frame_count = 0
+ background_im = None
+ for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
+ for im_frame in ImageSequence.Iterator(imSequence):
+ # a copy is required here since seek can still mutate the image
+ im_frame = _normalize_mode(im_frame.copy())
+ if frame_count == 0:
+ for k, v in im_frame.info.items():
+ if k == "transparency":
+ continue
+ if isinstance(k, str):
+ im.encoderinfo.setdefault(k, v)
+
+ encoderinfo = im.encoderinfo.copy()
+ if "transparency" in im_frame.info:
+ encoderinfo.setdefault("transparency", im_frame.info["transparency"])
+ im_frame = _normalize_palette(im_frame, palette, encoderinfo)
+ if isinstance(duration, (list, tuple)):
+ encoderinfo["duration"] = duration[frame_count]
+ elif duration is None and "duration" in im_frame.info:
+ encoderinfo["duration"] = im_frame.info["duration"]
+ if isinstance(disposal, (list, tuple)):
+ encoderinfo["disposal"] = disposal[frame_count]
+ frame_count += 1
+
+ diff_frame = None
+ if im_frames and previous_im:
+ # delta frame
+ delta, bbox = _getbbox(previous_im, im_frame)
+ if not bbox:
+ # This frame is identical to the previous frame
+ if encoderinfo.get("duration"):
+ im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
+ continue
+ if im_frames[-1].encoderinfo.get("disposal") == 2:
+ if background_im is None:
+ color = im.encoderinfo.get(
+ "transparency", im.info.get("transparency", (0, 0, 0))
+ )
+ background = _get_background(im_frame, color)
+ background_im = Image.new("P", im_frame.size, background)
+ first_palette = im_frames[0].im.palette
+ assert first_palette is not None
+ background_im.putpalette(first_palette, first_palette.mode)
+ bbox = _getbbox(background_im, im_frame)[1]
+ elif encoderinfo.get("optimize") and im_frame.mode != "1":
+ if "transparency" not in encoderinfo:
+ assert im_frame.palette is not None
+ try:
+ encoderinfo["transparency"] = (
+ im_frame.palette._new_color_index(im_frame)
+ )
+ except ValueError:
+ pass
+ if "transparency" in encoderinfo:
+ # When the delta is zero, fill the image with transparency
+ diff_frame = im_frame.copy()
+ fill = Image.new("P", delta.size, encoderinfo["transparency"])
+ if delta.mode == "RGBA":
+ r, g, b, a = delta.split()
+ mask = ImageMath.lambda_eval(
+ lambda args: args["convert"](
+ args["max"](
+ args["max"](
+ args["max"](args["r"], args["g"]), args["b"]
+ ),
+ args["a"],
+ )
+ * 255,
+ "1",
+ ),
+ r=r,
+ g=g,
+ b=b,
+ a=a,
+ )
+ else:
+ if delta.mode == "P":
+ # Convert to L without considering palette
+ delta_l = Image.new("L", delta.size)
+ delta_l.putdata(delta.getdata())
+ delta = delta_l
+ mask = ImageMath.lambda_eval(
+ lambda args: args["convert"](args["im"] * 255, "1"),
+ im=delta,
+ )
+ diff_frame.paste(fill, mask=ImageOps.invert(mask))
+ else:
+ bbox = None
+ previous_im = im_frame
+ im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo))
+
+ if len(im_frames) == 1:
+ if "duration" in im.encoderinfo:
+ # Since multiple frames will not be written, use the combined duration
+ im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"]
+ return False
+
+ for frame_data in im_frames:
+ im_frame = frame_data.im
+ if not frame_data.bbox:
+ # global header
+ for s in _get_global_header(im_frame, frame_data.encoderinfo):
+ fp.write(s)
+ offset = (0, 0)
+ else:
+ # compress difference
+ if not palette:
+ frame_data.encoderinfo["include_color_table"] = True
+
+ im_frame = im_frame.crop(frame_data.bbox)
+ offset = frame_data.bbox[:2]
+ _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo)
+ return True
+
+
+def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
+ _save(im, fp, filename, save_all=True)
+
+
+def _save(
+ im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
+) -> None:
+ # header
+ if "palette" in im.encoderinfo or "palette" in im.info:
+ palette = im.encoderinfo.get("palette", im.info.get("palette"))
+ else:
+ palette = None
+ im.encoderinfo.setdefault("optimize", True)
+
+ if not save_all or not _write_multiple_frames(im, fp, palette):
+ _write_single_frame(im, fp, palette)
+
+ fp.write(b";") # end of file
+
+ if hasattr(fp, "flush"):
+ fp.flush()
+
+
+def get_interlace(im: Image.Image) -> int:
+ interlace = im.encoderinfo.get("interlace", 1)
+
+ # workaround for @PIL153
+ if min(im.size) < 16:
+ interlace = 0
+
+ return interlace
+
+
+def _write_local_header(
+ fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
+) -> None:
+ try:
+ transparency = im.encoderinfo["transparency"]
+ except KeyError:
+ transparency = None
+
+ if "duration" in im.encoderinfo:
+ duration = int(im.encoderinfo["duration"] / 10)
+ else:
+ duration = 0
+
+ disposal = int(im.encoderinfo.get("disposal", 0))
+
+ if transparency is not None or duration != 0 or disposal:
+ packed_flag = 1 if transparency is not None else 0
+ packed_flag |= disposal << 2
+
+ fp.write(
+ b"!"
+ + o8(249) # extension intro
+ + o8(4) # length
+ + o8(packed_flag) # packed fields
+ + o16(duration) # duration
+ + o8(transparency or 0) # transparency index
+ + o8(0)
+ )
+
+ include_color_table = im.encoderinfo.get("include_color_table")
+ if include_color_table:
+ palette_bytes = _get_palette_bytes(im)
+ color_table_size = _get_color_table_size(palette_bytes)
+ if color_table_size:
+ flags = flags | 128 # local color table flag
+ flags = flags | color_table_size
+
+ fp.write(
+ b","
+ + o16(offset[0]) # offset
+ + o16(offset[1])
+ + o16(im.size[0]) # size
+ + o16(im.size[1])
+ + o8(flags) # flags
+ )
+ if include_color_table and color_table_size:
+ fp.write(_get_header_palette(palette_bytes))
+ fp.write(o8(8)) # bits
+
+
+def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
+ # Unused by default.
+ # To use, uncomment the register_save call at the end of the file.
+ #
+ # If you need real GIF compression and/or RGB quantization, you
+ # can use the external NETPBM/PBMPLUS utilities. See comments
+ # below for information on how to enable this.
+ tempfile = im._dump()
+
+ try:
+ with open(filename, "wb") as f:
+ if im.mode != "RGB":
+ subprocess.check_call(
+ ["ppmtogif", tempfile], stdout=f, stderr=subprocess.DEVNULL
+ )
+ else:
+ # Pipe ppmquant output into ppmtogif
+ # "ppmquant 256 %s | ppmtogif > %s" % (tempfile, filename)
+ quant_cmd = ["ppmquant", "256", tempfile]
+ togif_cmd = ["ppmtogif"]
+ quant_proc = subprocess.Popen(
+ quant_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
+ )
+ togif_proc = subprocess.Popen(
+ togif_cmd,
+ stdin=quant_proc.stdout,
+ stdout=f,
+ stderr=subprocess.DEVNULL,
+ )
+
+ # Allow ppmquant to receive SIGPIPE if ppmtogif exits
+ assert quant_proc.stdout is not None
+ quant_proc.stdout.close()
+
+ retcode = quant_proc.wait()
+ if retcode:
+ raise subprocess.CalledProcessError(retcode, quant_cmd)
+
+ retcode = togif_proc.wait()
+ if retcode:
+ raise subprocess.CalledProcessError(retcode, togif_cmd)
+ finally:
+ try:
+ os.unlink(tempfile)
+ except OSError:
+ pass
+
+
+# Force optimization so that we can test performance against
+# cases where it took lots of memory and time previously.
+_FORCE_OPTIMIZE = False
+
+
+def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
+ """
+ Palette optimization is a potentially expensive operation.
+
+ This function determines if the palette should be optimized using
+ some heuristics, then returns the list of palette entries in use.
+
+ :param im: Image object
+ :param info: encoderinfo
+ :returns: list of indexes of palette entries in use, or None
+ """
+ if im.mode in ("P", "L") and info and info.get("optimize"):
+ # Potentially expensive operation.
+
+ # The palette saves 3 bytes per color not used, but palette
+ # lengths are restricted to 3*(2**N) bytes. Max saving would
+ # be 768 -> 6 bytes if we went all the way down to 2 colors.
+ # * If we're over 128 colors, we can't save any space.
+ # * If there aren't any holes, it's not worth collapsing.
+ # * If we have a 'large' image, the palette is in the noise.
+
+ # create the new palette if not every color is used
+ optimise = _FORCE_OPTIMIZE or im.mode == "L"
+ if optimise or im.width * im.height < 512 * 512:
+ # check which colors are used
+ used_palette_colors = []
+ for i, count in enumerate(im.histogram()):
+ if count:
+ used_palette_colors.append(i)
+
+ if optimise or max(used_palette_colors) >= len(used_palette_colors):
+ return used_palette_colors
+
+ assert im.palette is not None
+ num_palette_colors = len(im.palette.palette) // Image.getmodebands(
+ im.palette.mode
+ )
+ current_palette_size = 1 << (num_palette_colors - 1).bit_length()
+ if (
+ # check that the palette would become smaller when saved
+ len(used_palette_colors) <= current_palette_size // 2
+ # check that the palette is not already the smallest possible size
+ and current_palette_size > 2
+ ):
+ return used_palette_colors
+ return None
+
+
+def _get_color_table_size(palette_bytes: bytes) -> int:
+ # calculate the palette size for the header
+ if not palette_bytes:
+ return 0
+ elif len(palette_bytes) < 9:
+ return 1
+ else:
+ return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1
+
+
+def _get_header_palette(palette_bytes: bytes) -> bytes:
+ """
+ Returns the palette, null padded to the next power of 2 (*3) bytes
+ suitable for direct inclusion in the GIF header
+
+ :param palette_bytes: Unpadded palette bytes, in RGBRGB form
+ :returns: Null padded palette
+ """
+ color_table_size = _get_color_table_size(palette_bytes)
+
+ # add the missing amount of bytes
+ # the palette has to be 2<<n in size
+ actual_target_size_diff = (2 << color_table_size) - len(palette_bytes) // 3
+ if actual_target_size_diff > 0:
+ palette_bytes += o8(0) * 3 * actual_target_size_diff
+ return palette_bytes
+
+
+def _get_palette_bytes(im: Image.Image) -> bytes:
+ """
+ Gets the palette for inclusion in the gif header
+
+ :param im: Image object
+ :returns: Bytes, len<=768 suitable for inclusion in gif header
+ """
+ if not im.palette:
+ return b""
+
+ palette = bytes(im.palette.palette)
+ if im.palette.mode == "RGBA":
+ palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3))
+ return palette
+
+
+def _get_background(
+ im: Image.Image,
+ info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
+) -> int:
+ background = 0
+ if info_background:
+ if isinstance(info_background, tuple):
+ # WebPImagePlugin stores an RGBA value in info["background"]
+ # So it must be converted to the same format as GifImagePlugin's
+ # info["background"] - a global color table index
+ assert im.palette is not None
+ try:
+ background = im.palette.getcolor(info_background, im)
+ except ValueError as e:
+ if str(e) not in (
+ # If all 256 colors are in use,
+ # then there is no need for the background color
+ "cannot allocate more than 256 colors",
+ # Ignore non-opaque WebP background
+ "cannot add non-opaque RGBA color to RGB palette",
+ ):
+ raise
+ else:
+ background = info_background
+ return background
+
+
+def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
+ """Return a list of strings representing a GIF header"""
+
+ # Header Block
+ # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
+
+ version = b"87a"
+ if im.info.get("version") == b"89a" or (
+ info
+ and (
+ "transparency" in info
+ or info.get("loop") is not None
+ or info.get("duration")
+ or info.get("comment")
+ )
+ ):
+ version = b"89a"
+
+ background = _get_background(im, info.get("background"))
+
+ palette_bytes = _get_palette_bytes(im)
+ color_table_size = _get_color_table_size(palette_bytes)
+
+ header = [
+ b"GIF" # signature
+ + version # version
+ + o16(im.size[0]) # canvas width
+ + o16(im.size[1]), # canvas height
+ # Logical Screen Descriptor
+ # size of global color table + global color table flag
+ o8(color_table_size + 128), # packed fields
+ # background + reserved/aspect
+ o8(background) + o8(0),
+ # Global Color Table
+ _get_header_palette(palette_bytes),
+ ]
+ if info.get("loop") is not None:
+ header.append(
+ b"!"
+ + o8(255) # extension intro
+ + o8(11)
+ + b"NETSCAPE2.0"
+ + o8(3)
+ + o8(1)
+ + o16(info["loop"]) # number of loops
+ + o8(0)
+ )
+ if info.get("comment"):
+ comment_block = b"!" + o8(254) # extension intro
+
+ comment = info["comment"]
+ if isinstance(comment, str):
+ comment = comment.encode()
+ for i in range(0, len(comment), 255):
+ subblock = comment[i : i + 255]
+ comment_block += o8(len(subblock)) + subblock
+
+ comment_block += o8(0)
+ header.append(comment_block)
+ return header
+
+
+def _write_frame_data(
+ fp: IO[bytes],
+ im_frame: Image.Image,
+ offset: tuple[int, int],
+ params: dict[str, Any],
+) -> None:
+ try:
+ im_frame.encoderinfo = params
+
+ # local image header
+ _write_local_header(fp, im_frame, offset, 0)
+
+ ImageFile._save(
+ im_frame,
+ fp,
+ [ImageFile._Tile("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])],
+ )
+
+ fp.write(b"\0") # end of image data
+ finally:
+ del im_frame.encoderinfo
+
+
+# --------------------------------------------------------------------
+# Legacy GIF utilities
+
+
+def getheader(
+ im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None
+) -> tuple[list[bytes], list[int] | None]:
+ """
+ Legacy Method to get Gif data from image.
+
+ Warning:: May modify image data.
+
+ :param im: Image object
+ :param palette: bytes object containing the source palette, or ....
+ :param info: encoderinfo
+ :returns: tuple of(list of header items, optimized palette)
+
+ """
+ if info is None:
+ info = {}
+
+ used_palette_colors = _get_optimize(im, info)
+
+ if "background" not in info and "background" in im.info:
+ info["background"] = im.info["background"]
+
+ im_mod = _normalize_palette(im, palette, info)
+ im.palette = im_mod.palette
+ im.im = im_mod.im
+ header = _get_global_header(im, info)
+
+ return header, used_palette_colors
+
+
+def getdata(
+ im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
+) -> list[bytes]:
+ """
+ Legacy Method
+
+ Return a list of strings representing this image.
+ The first string is a local image header, the rest contains
+ encoded image data.
+
+ To specify duration, add the time in milliseconds,
+ e.g. ``getdata(im_frame, duration=1000)``
+
+ :param im: Image object
+ :param offset: Tuple of (x, y) pixels. Defaults to (0, 0)
+ :param \\**params: e.g. duration or other encoder info parameters
+ :returns: List of bytes containing GIF encoded frame data
+
+ """
+ from io import BytesIO
+
+ class Collector(BytesIO):
+ data = []
+
+ def write(self, data: Buffer) -> int:
+ self.data.append(data)
+ return len(data)
+
+ im.load() # make sure raster data is available
+
+ fp = Collector()
+
+ _write_frame_data(fp, im, offset, params)
+
+ return fp.data
+
+
+# --------------------------------------------------------------------
+# Registry
+
+Image.register_open(GifImageFile.format, GifImageFile, _accept)
+Image.register_save(GifImageFile.format, _save)
+Image.register_save_all(GifImageFile.format, _save_all)
+Image.register_extension(GifImageFile.format, ".gif")
+Image.register_mime(GifImageFile.format, "image/gif")
+
+#
+# Uncomment the following line if you wish to use NETPBM/PBMPLUS
+# instead of the built-in "uncompressed" GIF encoder
+
+# Image.register_save(GifImageFile.format, _save_netpbm)