|
@@ -0,0 +1,888 @@
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+import itertools
|
|
|
+import math
|
|
|
+import os
|
|
|
+import subprocess
|
|
|
+
|
|
|
+from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
|
|
|
+from ._binary import i8
|
|
|
+from ._binary import i16le as i16
|
|
|
+from ._binary import o8
|
|
|
+from ._binary import o16le as o16
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+def _accept(prefix):
|
|
|
+ return prefix[:6] in [b"GIF87a", b"GIF89a"]
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+class GifImageFile(ImageFile.ImageFile):
|
|
|
+
|
|
|
+ format = "GIF"
|
|
|
+ format_description = "Compuserve GIF"
|
|
|
+ _close_exclusive_fp_after_loading = False
|
|
|
+
|
|
|
+ global_palette = None
|
|
|
+
|
|
|
+ def data(self):
|
|
|
+ s = self.fp.read(1)
|
|
|
+ if s and i8(s):
|
|
|
+ return self.fp.read(i8(s))
|
|
|
+ return None
|
|
|
+
|
|
|
+ def _open(self):
|
|
|
+
|
|
|
+
|
|
|
+ s = self.fp.read(13)
|
|
|
+ if not _accept(s):
|
|
|
+ raise SyntaxError("not a GIF file")
|
|
|
+
|
|
|
+ self.info["version"] = s[:6]
|
|
|
+ self._size = i16(s[6:]), i16(s[8:])
|
|
|
+ self.tile = []
|
|
|
+ flags = i8(s[10])
|
|
|
+ bits = (flags & 7) + 1
|
|
|
+
|
|
|
+ if flags & 128:
|
|
|
+
|
|
|
+ self.info["background"] = i8(s[11])
|
|
|
+
|
|
|
+ p = self.fp.read(3 << bits)
|
|
|
+ for i in range(0, len(p), 3):
|
|
|
+ if not (i // 3 == i8(p[i]) == i8(p[i + 1]) == i8(p[i + 2])):
|
|
|
+ p = ImagePalette.raw("RGB", p)
|
|
|
+ self.global_palette = self.palette = p
|
|
|
+ break
|
|
|
+
|
|
|
+ self.__fp = self.fp
|
|
|
+ self.__rewind = self.fp.tell()
|
|
|
+ self._n_frames = None
|
|
|
+ self._is_animated = None
|
|
|
+ self._seek(0)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def n_frames(self):
|
|
|
+ if self._n_frames is None:
|
|
|
+ current = self.tell()
|
|
|
+ try:
|
|
|
+ while True:
|
|
|
+ self.seek(self.tell() + 1)
|
|
|
+ except EOFError:
|
|
|
+ self._n_frames = self.tell() + 1
|
|
|
+ self.seek(current)
|
|
|
+ return self._n_frames
|
|
|
+
|
|
|
+ @property
|
|
|
+ def is_animated(self):
|
|
|
+ if self._is_animated is None:
|
|
|
+ if self._n_frames is not None:
|
|
|
+ self._is_animated = self._n_frames != 1
|
|
|
+ else:
|
|
|
+ current = self.tell()
|
|
|
+
|
|
|
+ try:
|
|
|
+ self.seek(1)
|
|
|
+ self._is_animated = True
|
|
|
+ except EOFError:
|
|
|
+ self._is_animated = False
|
|
|
+
|
|
|
+ self.seek(current)
|
|
|
+ return self._is_animated
|
|
|
+
|
|
|
+ def seek(self, frame):
|
|
|
+ if not self._seek_check(frame):
|
|
|
+ return
|
|
|
+ if frame < self.__frame:
|
|
|
+ if frame != 0:
|
|
|
+ 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)
|
|
|
+ raise EOFError("no more images in GIF file") from e
|
|
|
+
|
|
|
+ def _seek(self, frame):
|
|
|
+
|
|
|
+ if frame == 0:
|
|
|
+
|
|
|
+ self.__offset = 0
|
|
|
+ self.dispose = None
|
|
|
+ self.dispose_extent = [0, 0, 0, 0]
|
|
|
+ self.__frame = -1
|
|
|
+ self.__fp.seek(self.__rewind)
|
|
|
+ self._prev_im = None
|
|
|
+ self.disposal_method = 0
|
|
|
+ else:
|
|
|
+
|
|
|
+ if not self.im:
|
|
|
+ self.load()
|
|
|
+
|
|
|
+ if frame != self.__frame + 1:
|
|
|
+ raise ValueError(f"cannot seek to frame {frame}")
|
|
|
+ self.__frame = frame
|
|
|
+
|
|
|
+ self.tile = []
|
|
|
+
|
|
|
+ self.fp = self.__fp
|
|
|
+ if self.__offset:
|
|
|
+
|
|
|
+ self.fp.seek(self.__offset)
|
|
|
+ while self.data():
|
|
|
+ pass
|
|
|
+ self.__offset = 0
|
|
|
+
|
|
|
+ if self.dispose:
|
|
|
+ self.im.paste(self.dispose, self.dispose_extent)
|
|
|
+
|
|
|
+ from copy import copy
|
|
|
+
|
|
|
+ self.palette = copy(self.global_palette)
|
|
|
+
|
|
|
+ info = {}
|
|
|
+ while True:
|
|
|
+
|
|
|
+ s = self.fp.read(1)
|
|
|
+ if not s or s == b";":
|
|
|
+ break
|
|
|
+
|
|
|
+ elif s == b"!":
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ s = self.fp.read(1)
|
|
|
+ block = self.data()
|
|
|
+ if i8(s) == 249:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ flags = i8(block[0])
|
|
|
+ if flags & 1:
|
|
|
+ info["transparency"] = i8(block[3])
|
|
|
+ info["duration"] = i16(block[1:3]) * 10
|
|
|
+
|
|
|
+
|
|
|
+ dispose_bits = 0b00011100 & flags
|
|
|
+ dispose_bits = dispose_bits >> 2
|
|
|
+ if dispose_bits:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ self.disposal_method = dispose_bits
|
|
|
+ elif i8(s) == 254:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ while block:
|
|
|
+ if "comment" in info:
|
|
|
+ info["comment"] += block
|
|
|
+ else:
|
|
|
+ info["comment"] = block
|
|
|
+ block = self.data()
|
|
|
+ continue
|
|
|
+ elif i8(s) == 255:
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ info["extension"] = block, self.fp.tell()
|
|
|
+ if block[:11] == b"NETSCAPE2.0":
|
|
|
+ block = self.data()
|
|
|
+ if len(block) >= 3 and i8(block[0]) == 1:
|
|
|
+ info["loop"] = i16(block[1:3])
|
|
|
+ while self.data():
|
|
|
+ pass
|
|
|
+
|
|
|
+ elif s == b",":
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ s = self.fp.read(9)
|
|
|
+
|
|
|
+
|
|
|
+ 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]:
|
|
|
+ self._size = max(x1, self.size[0]), max(y1, self.size[1])
|
|
|
+ self.dispose_extent = x0, y0, x1, y1
|
|
|
+ flags = i8(s[8])
|
|
|
+
|
|
|
+ interlace = (flags & 64) != 0
|
|
|
+
|
|
|
+ if flags & 128:
|
|
|
+ bits = (flags & 7) + 1
|
|
|
+ self.palette = ImagePalette.raw("RGB", self.fp.read(3 << bits))
|
|
|
+
|
|
|
+
|
|
|
+ bits = i8(self.fp.read(1))
|
|
|
+ self.__offset = self.fp.tell()
|
|
|
+ self.tile = [
|
|
|
+ ("gif", (x0, y0, x1, y1), self.__offset, (bits, interlace))
|
|
|
+ ]
|
|
|
+ break
|
|
|
+
|
|
|
+ else:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+ try:
|
|
|
+ if self.disposal_method < 2:
|
|
|
+
|
|
|
+ self.dispose = None
|
|
|
+ elif self.disposal_method == 2:
|
|
|
+
|
|
|
+ Image._decompression_bomb_check(self.size)
|
|
|
+ self.dispose = Image.core.fill("P", self.size, self.info["background"])
|
|
|
+ else:
|
|
|
+
|
|
|
+ if self.im:
|
|
|
+ self.dispose = self.im.copy()
|
|
|
+
|
|
|
+
|
|
|
+ if self.dispose:
|
|
|
+ self.dispose = self._crop(self.dispose, self.dispose_extent)
|
|
|
+ except (AttributeError, KeyError):
|
|
|
+ pass
|
|
|
+
|
|
|
+ if not self.tile:
|
|
|
+
|
|
|
+ raise EOFError
|
|
|
+
|
|
|
+ for k in ["transparency", "duration", "comment", "extension", "loop"]:
|
|
|
+ if k in info:
|
|
|
+ self.info[k] = info[k]
|
|
|
+ elif k in self.info:
|
|
|
+ del self.info[k]
|
|
|
+
|
|
|
+ self.mode = "L"
|
|
|
+ if self.palette:
|
|
|
+ self.mode = "P"
|
|
|
+
|
|
|
+ def tell(self):
|
|
|
+ return self.__frame
|
|
|
+
|
|
|
+ def load_end(self):
|
|
|
+ ImageFile.ImageFile.load_end(self)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ if self._prev_im and self.disposal_method == 1:
|
|
|
+
|
|
|
+
|
|
|
+ updated = self._crop(self.im, self.dispose_extent)
|
|
|
+ self._prev_im.paste(updated, self.dispose_extent, updated.convert("RGBA"))
|
|
|
+ self.im = self._prev_im
|
|
|
+ self._prev_im = self.im.copy()
|
|
|
+
|
|
|
+ def _close__fp(self):
|
|
|
+ try:
|
|
|
+ if self.__fp != self.fp:
|
|
|
+ self.__fp.close()
|
|
|
+ except AttributeError:
|
|
|
+ pass
|
|
|
+ finally:
|
|
|
+ self.__fp = None
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+RAWMODE = {"1": "L", "L": "L", "P": "P"}
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_mode(im, initial_call=False):
|
|
|
+ """
|
|
|
+ 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.
|
|
|
+
|
|
|
+ UNDONE: What is the point of mucking with the initial call palette, for
|
|
|
+ an image that shouldn't have a palette, or it would be a mode 'P' and
|
|
|
+ get returned in the RAWMODE clause.
|
|
|
+
|
|
|
+ :param im: Image object
|
|
|
+ :param initial_call: Default false, set to true for a single frame.
|
|
|
+ :returns: Image object
|
|
|
+ """
|
|
|
+ if im.mode in RAWMODE:
|
|
|
+ im.load()
|
|
|
+ return im
|
|
|
+ if Image.getmodebase(im.mode) == "RGB":
|
|
|
+ if initial_call:
|
|
|
+ palette_size = 256
|
|
|
+ if im.palette:
|
|
|
+ palette_size = len(im.palette.getdata()[1]) // 3
|
|
|
+ return im.convert("P", palette=Image.ADAPTIVE, colors=palette_size)
|
|
|
+ else:
|
|
|
+ return im.convert("P")
|
|
|
+ return im.convert("L")
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_palette(im, palette, info):
|
|
|
+ """
|
|
|
+ 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:
|
|
|
+
|
|
|
+ if isinstance(palette, (bytes, bytearray, list)):
|
|
|
+ source_palette = bytearray(palette[:768])
|
|
|
+ if isinstance(palette, ImagePalette.ImagePalette):
|
|
|
+ source_palette = bytearray(
|
|
|
+ itertools.chain.from_iterable(
|
|
|
+ zip(
|
|
|
+ palette.palette[:256],
|
|
|
+ palette.palette[256:512],
|
|
|
+ palette.palette[512:768],
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ if im.mode == "P":
|
|
|
+ if not source_palette:
|
|
|
+ source_palette = im.im.getpalette("RGB")[:768]
|
|
|
+ else:
|
|
|
+ if not source_palette:
|
|
|
+ source_palette = bytearray(i // 3 for i in range(768))
|
|
|
+ im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
|
|
|
+
|
|
|
+ used_palette_colors = _get_optimize(im, info)
|
|
|
+ if used_palette_colors is not None:
|
|
|
+ return im.remap_palette(used_palette_colors, source_palette)
|
|
|
+
|
|
|
+ im.palette.palette = source_palette
|
|
|
+ return im
|
|
|
+
|
|
|
+
|
|
|
+def _write_single_frame(im, fp, palette):
|
|
|
+ im_out = _normalize_mode(im, True)
|
|
|
+ for k, v in im_out.info.items():
|
|
|
+ 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)
|
|
|
+
|
|
|
+
|
|
|
+ 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, [("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])])
|
|
|
+
|
|
|
+ fp.write(b"\0")
|
|
|
+
|
|
|
+
|
|
|
+def _write_multiple_frames(im, fp, palette):
|
|
|
+
|
|
|
+ duration = im.encoderinfo.get("duration", im.info.get("duration"))
|
|
|
+ disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
|
|
|
+
|
|
|
+ im_frames = []
|
|
|
+ frame_count = 0
|
|
|
+ background_im = None
|
|
|
+ for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
|
|
|
+ for im_frame in ImageSequence.Iterator(imSequence):
|
|
|
+
|
|
|
+ im_frame = _normalize_mode(im_frame.copy())
|
|
|
+ if frame_count == 0:
|
|
|
+ for k, v in im_frame.info.items():
|
|
|
+ im.encoderinfo.setdefault(k, v)
|
|
|
+ im_frame = _normalize_palette(im_frame, palette, im.encoderinfo)
|
|
|
+
|
|
|
+ encoderinfo = im.encoderinfo.copy()
|
|
|
+ if isinstance(duration, (list, tuple)):
|
|
|
+ encoderinfo["duration"] = duration[frame_count]
|
|
|
+ if isinstance(disposal, (list, tuple)):
|
|
|
+ encoderinfo["disposal"] = disposal[frame_count]
|
|
|
+ frame_count += 1
|
|
|
+
|
|
|
+ if im_frames:
|
|
|
+
|
|
|
+ previous = im_frames[-1]
|
|
|
+ if encoderinfo.get("disposal") == 2:
|
|
|
+ if background_im is None:
|
|
|
+ background = _get_background(
|
|
|
+ im,
|
|
|
+ im.encoderinfo.get("background", im.info.get("background")),
|
|
|
+ )
|
|
|
+ background_im = Image.new("P", im_frame.size, background)
|
|
|
+ background_im.putpalette(im_frames[0]["im"].palette)
|
|
|
+ base_im = background_im
|
|
|
+ else:
|
|
|
+ base_im = previous["im"]
|
|
|
+ if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im):
|
|
|
+ delta = ImageChops.subtract_modulo(im_frame, base_im)
|
|
|
+ else:
|
|
|
+ delta = ImageChops.subtract_modulo(
|
|
|
+ im_frame.convert("RGB"), base_im.convert("RGB")
|
|
|
+ )
|
|
|
+ bbox = delta.getbbox()
|
|
|
+ if not bbox:
|
|
|
+
|
|
|
+ if duration:
|
|
|
+ previous["encoderinfo"]["duration"] += encoderinfo["duration"]
|
|
|
+ continue
|
|
|
+ else:
|
|
|
+ bbox = None
|
|
|
+ im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo})
|
|
|
+
|
|
|
+ if len(im_frames) > 1:
|
|
|
+ for frame_data in im_frames:
|
|
|
+ im_frame = frame_data["im"]
|
|
|
+ if not frame_data["bbox"]:
|
|
|
+
|
|
|
+ for s in _get_global_header(im_frame, frame_data["encoderinfo"]):
|
|
|
+ fp.write(s)
|
|
|
+ offset = (0, 0)
|
|
|
+ else:
|
|
|
+
|
|
|
+ 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
|
|
|
+ elif "duration" in im.encoderinfo and isinstance(
|
|
|
+ im.encoderinfo["duration"], (list, tuple)
|
|
|
+ ):
|
|
|
+
|
|
|
+ im.encoderinfo["duration"] = sum(im.encoderinfo["duration"])
|
|
|
+
|
|
|
+
|
|
|
+def _save_all(im, fp, filename):
|
|
|
+ _save(im, fp, filename, save_all=True)
|
|
|
+
|
|
|
+
|
|
|
+def _save(im, fp, filename, save_all=False):
|
|
|
+
|
|
|
+ if "palette" in im.encoderinfo or "palette" in im.info:
|
|
|
+ palette = im.encoderinfo.get("palette", im.info.get("palette"))
|
|
|
+ else:
|
|
|
+ palette = None
|
|
|
+ im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True)
|
|
|
+
|
|
|
+ if not save_all or not _write_multiple_frames(im, fp, palette):
|
|
|
+ _write_single_frame(im, fp, palette)
|
|
|
+
|
|
|
+ fp.write(b";")
|
|
|
+
|
|
|
+ if hasattr(fp, "flush"):
|
|
|
+ fp.flush()
|
|
|
+
|
|
|
+
|
|
|
+def get_interlace(im):
|
|
|
+ interlace = im.encoderinfo.get("interlace", 1)
|
|
|
+
|
|
|
+
|
|
|
+ if min(im.size) < 16:
|
|
|
+ interlace = 0
|
|
|
+
|
|
|
+ return interlace
|
|
|
+
|
|
|
+
|
|
|
+def _write_local_header(fp, im, offset, flags):
|
|
|
+ transparent_color_exists = False
|
|
|
+ try:
|
|
|
+ transparency = im.encoderinfo["transparency"]
|
|
|
+ except KeyError:
|
|
|
+ pass
|
|
|
+ else:
|
|
|
+ transparency = int(transparency)
|
|
|
+
|
|
|
+ transparent_color_exists = True
|
|
|
+
|
|
|
+ used_palette_colors = _get_optimize(im, im.encoderinfo)
|
|
|
+ if used_palette_colors is not None:
|
|
|
+
|
|
|
+ try:
|
|
|
+ transparency = used_palette_colors.index(transparency)
|
|
|
+ except ValueError:
|
|
|
+ transparent_color_exists = False
|
|
|
+
|
|
|
+ if "duration" in im.encoderinfo:
|
|
|
+ duration = int(im.encoderinfo["duration"] / 10)
|
|
|
+ else:
|
|
|
+ duration = 0
|
|
|
+
|
|
|
+ disposal = int(im.encoderinfo.get("disposal", 0))
|
|
|
+
|
|
|
+ if transparent_color_exists or duration != 0 or disposal:
|
|
|
+ packed_flag = 1 if transparent_color_exists else 0
|
|
|
+ packed_flag |= disposal << 2
|
|
|
+ if not transparent_color_exists:
|
|
|
+ transparency = 0
|
|
|
+
|
|
|
+ fp.write(
|
|
|
+ b"!"
|
|
|
+ + o8(249)
|
|
|
+ + o8(4)
|
|
|
+ + o8(packed_flag)
|
|
|
+ + o16(duration)
|
|
|
+ + o8(transparency)
|
|
|
+ + o8(0)
|
|
|
+ )
|
|
|
+
|
|
|
+ if "comment" in im.encoderinfo and 1 <= len(im.encoderinfo["comment"]):
|
|
|
+ fp.write(b"!" + o8(254))
|
|
|
+ comment = im.encoderinfo["comment"]
|
|
|
+ if isinstance(comment, str):
|
|
|
+ comment = comment.encode()
|
|
|
+ for i in range(0, len(comment), 255):
|
|
|
+ subblock = comment[i : i + 255]
|
|
|
+ fp.write(o8(len(subblock)) + subblock)
|
|
|
+ fp.write(o8(0))
|
|
|
+ if "loop" in im.encoderinfo:
|
|
|
+ number_of_loops = im.encoderinfo["loop"]
|
|
|
+ fp.write(
|
|
|
+ b"!"
|
|
|
+ + o8(255)
|
|
|
+ + o8(11)
|
|
|
+ + b"NETSCAPE2.0"
|
|
|
+ + o8(3)
|
|
|
+ + o8(1)
|
|
|
+ + o16(number_of_loops)
|
|
|
+ + 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
|
|
|
+ flags = flags | color_table_size
|
|
|
+
|
|
|
+ fp.write(
|
|
|
+ b","
|
|
|
+ + o16(offset[0])
|
|
|
+ + o16(offset[1])
|
|
|
+ + o16(im.size[0])
|
|
|
+ + o16(im.size[1])
|
|
|
+ + o8(flags)
|
|
|
+ )
|
|
|
+ if include_color_table and color_table_size:
|
|
|
+ fp.write(_get_header_palette(palette_bytes))
|
|
|
+ fp.write(o8(8))
|
|
|
+
|
|
|
+
|
|
|
+def _save_netpbm(im, fp, filename):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ 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:
|
|
|
+
|
|
|
+
|
|
|
+ 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,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+ 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_OPTIMIZE = False
|
|
|
+
|
|
|
+
|
|
|
+def _get_optimize(im, info):
|
|
|
+ """
|
|
|
+ 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", 0):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ optimise = _FORCE_OPTIMIZE or im.mode == "L"
|
|
|
+ if optimise or im.width * im.height < 512 * 512:
|
|
|
+
|
|
|
+ used_palette_colors = []
|
|
|
+ for i, count in enumerate(im.histogram()):
|
|
|
+ if count:
|
|
|
+ used_palette_colors.append(i)
|
|
|
+
|
|
|
+ if optimise or (
|
|
|
+ len(used_palette_colors) <= 128
|
|
|
+ and max(used_palette_colors) > len(used_palette_colors)
|
|
|
+ ):
|
|
|
+ return used_palette_colors
|
|
|
+
|
|
|
+
|
|
|
+def _get_color_table_size(palette_bytes):
|
|
|
+
|
|
|
+ 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):
|
|
|
+ """
|
|
|
+ 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)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ 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):
|
|
|
+ """
|
|
|
+ Gets the palette for inclusion in the gif header
|
|
|
+
|
|
|
+ :param im: Image object
|
|
|
+ :returns: Bytes, len<=768 suitable for inclusion in gif header
|
|
|
+ """
|
|
|
+ return im.palette.palette
|
|
|
+
|
|
|
+
|
|
|
+def _get_background(im, infoBackground):
|
|
|
+ background = 0
|
|
|
+ if infoBackground:
|
|
|
+ background = infoBackground
|
|
|
+ if isinstance(background, tuple):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ background = im.palette.getcolor(background)
|
|
|
+ return background
|
|
|
+
|
|
|
+
|
|
|
+def _get_global_header(im, info):
|
|
|
+ """Return a list of strings representing a GIF header"""
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ version = b"87a"
|
|
|
+ for extensionKey in ["transparency", "duration", "loop", "comment"]:
|
|
|
+ if info and extensionKey in info:
|
|
|
+ if (extensionKey == "duration" and info[extensionKey] == 0) or (
|
|
|
+ extensionKey == "comment" and not (1 <= len(info[extensionKey]) <= 255)
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ version = b"89a"
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ if im.info.get("version") == b"89a":
|
|
|
+ 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)
|
|
|
+
|
|
|
+ return [
|
|
|
+ b"GIF"
|
|
|
+ + version
|
|
|
+ + o16(im.size[0])
|
|
|
+ + o16(im.size[1]),
|
|
|
+
|
|
|
+
|
|
|
+ o8(color_table_size + 128),
|
|
|
+
|
|
|
+ o8(background) + o8(0),
|
|
|
+
|
|
|
+ _get_header_palette(palette_bytes),
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def _write_frame_data(fp, im_frame, offset, params):
|
|
|
+ try:
|
|
|
+ im_frame.encoderinfo = params
|
|
|
+
|
|
|
+
|
|
|
+ _write_local_header(fp, im_frame, offset, 0)
|
|
|
+
|
|
|
+ ImageFile._save(
|
|
|
+ im_frame, fp, [("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])]
|
|
|
+ )
|
|
|
+
|
|
|
+ fp.write(b"\0")
|
|
|
+ finally:
|
|
|
+ del im_frame.encoderinfo
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+def getheader(im, palette=None, info=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)
|
|
|
+
|
|
|
+ """
|
|
|
+ used_palette_colors = _get_optimize(im, info)
|
|
|
+
|
|
|
+ if info is None:
|
|
|
+ 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, offset=(0, 0), **params):
|
|
|
+ """
|
|
|
+ Legacy Method
|
|
|
+
|
|
|
+ Return a list of strings representing this image.
|
|
|
+ The first string is a local image header, the rest contains
|
|
|
+ encoded image data.
|
|
|
+
|
|
|
+ :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
|
|
|
+
|
|
|
+ """
|
|
|
+
|
|
|
+ class Collector:
|
|
|
+ data = []
|
|
|
+
|
|
|
+ def write(self, data):
|
|
|
+ self.data.append(data)
|
|
|
+
|
|
|
+ im.load()
|
|
|
+
|
|
|
+ fp = Collector()
|
|
|
+
|
|
|
+ _write_frame_data(fp, im, offset, params)
|
|
|
+
|
|
|
+ return fp.data
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+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")
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|