Source code for PIL.GifImagePlugin

#
# 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


[docs] 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.
[docs] class GifImageFile(ImageFile.ImageFile): format = "GIF" format_description = "Compuserve GIF" _close_exclusive_fp_after_loading = False global_palette = None
[docs] 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) self.tile = [] 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
[docs] 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]
[docs] 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()
[docs] 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)
[docs] 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) assert im_frames[0].im.palette is not None background_im.putpalette(im_frames[0].im.palette) 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()
[docs] 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
[docs] 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
[docs] 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)