mirror of
https://github.com/python-pillow/Pillow.git
synced 2024-12-24 00:46:16 +03:00
Merge pull request #8128 from radarhere/type_hint_gif
This commit is contained in:
commit
ecf3a986ef
|
@ -53,6 +53,7 @@ def test_closed_file() -> None:
|
|||
|
||||
def test_seek_after_close() -> None:
|
||||
im = Image.open("Tests/images/iss634.gif")
|
||||
assert isinstance(im, GifImagePlugin.GifImageFile)
|
||||
im.load()
|
||||
im.close()
|
||||
|
||||
|
@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None:
|
|||
img = img.convert("RGB")
|
||||
|
||||
tempfile = str(tmp_path / "temp.gif")
|
||||
GifImagePlugin._save_netpbm(img, 0, tempfile)
|
||||
b = BytesIO()
|
||||
GifImagePlugin._save_netpbm(img, b, tempfile)
|
||||
with Image.open(tempfile) as reloaded:
|
||||
assert_image_similar(img, reloaded.convert("RGB"), 0)
|
||||
|
||||
|
@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
|
|||
img = img.convert("L")
|
||||
|
||||
tempfile = str(tmp_path / "temp.gif")
|
||||
GifImagePlugin._save_netpbm(img, 0, tempfile)
|
||||
b = BytesIO()
|
||||
GifImagePlugin._save_netpbm(img, b, tempfile)
|
||||
with Image.open(tempfile) as reloaded:
|
||||
assert_image_similar(img, reloaded.convert("L"), 0)
|
||||
|
||||
|
@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None:
|
|||
assert rgb_img.getpixel((50, 50)) == circle
|
||||
|
||||
# Check that frame transparency wasn't added unnecessarily
|
||||
assert img._frame_transparency is None
|
||||
assert getattr(img, "_frame_transparency") is None
|
||||
|
||||
|
||||
def test_dispose2_diff(tmp_path: Path) -> None:
|
||||
|
|
|
@ -29,9 +29,10 @@ import itertools
|
|||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import IntEnum
|
||||
from functools import cached_property
|
||||
from typing import IO
|
||||
from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union
|
||||
|
||||
from . import (
|
||||
Image,
|
||||
|
@ -46,6 +47,9 @@ from ._binary import i16le as i16
|
|||
from ._binary import o8
|
||||
from ._binary import o16le as o16
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import _imaging
|
||||
|
||||
|
||||
class LoadingStrategy(IntEnum):
|
||||
""".. versionadded:: 9.1.0"""
|
||||
|
@ -118,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
self._seek(0) # get ready to read first frame
|
||||
|
||||
@property
|
||||
def n_frames(self):
|
||||
def n_frames(self) -> int:
|
||||
if self._n_frames is None:
|
||||
current = self.tell()
|
||||
try:
|
||||
|
@ -163,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
msg = "no more images in GIF file"
|
||||
raise EOFError(msg) from e
|
||||
|
||||
def _seek(self, frame, update_image=True):
|
||||
def _seek(self, frame: int, update_image: bool = True) -> None:
|
||||
if frame == 0:
|
||||
# rewind
|
||||
self.__offset = 0
|
||||
self.dispose = None
|
||||
self.dispose: _imaging.ImagingCore | None = None
|
||||
self.__frame = -1
|
||||
self._fp.seek(self.__rewind)
|
||||
self.disposal_method = 0
|
||||
|
@ -195,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
msg = "no more images in GIF file"
|
||||
raise EOFError(msg)
|
||||
|
||||
palette = None
|
||||
palette: ImagePalette.ImagePalette | Literal[False] | None = None
|
||||
|
||||
info = {}
|
||||
info: dict[str, Any] = {}
|
||||
frame_transparency = None
|
||||
interlace = None
|
||||
frame_dispose_extent = None
|
||||
|
@ -213,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
#
|
||||
s = self.fp.read(1)
|
||||
block = self.data()
|
||||
if s[0] == 249:
|
||||
if s[0] == 249 and block is not None:
|
||||
#
|
||||
# graphic control extension
|
||||
#
|
||||
|
@ -249,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
info["comment"] = comment
|
||||
s = None
|
||||
continue
|
||||
elif s[0] == 255 and frame == 0:
|
||||
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 len(block) >= 3 and block[0] == 1:
|
||||
if block and len(block) >= 3 and block[0] == 1:
|
||||
self.info["loop"] = i16(block, 1)
|
||||
while self.data():
|
||||
pass
|
||||
|
@ -345,51 +349,52 @@ class GifImageFile(ImageFile.ImageFile):
|
|||
else:
|
||||
return (color, color, color)
|
||||
|
||||
self.dispose = None
|
||||
self.dispose_extent = frame_dispose_extent
|
||||
try:
|
||||
if self.disposal_method < 2:
|
||||
# do not dispose or none specified
|
||||
self.dispose = None
|
||||
elif self.disposal_method == 2:
|
||||
# replace with background colour
|
||||
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)
|
||||
|
||||
# by convention, attempt to use transparency first
|
||||
dispose_mode = "P"
|
||||
color = frame_transparency
|
||||
if self.mode in ("RGB", "RGBA"):
|
||||
dispose_mode = "RGBA"
|
||||
color = _rgb(frame_transparency) + (0,)
|
||||
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)
|
||||
except AttributeError:
|
||||
pass
|
||||
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
|
||||
|
@ -498,7 +503,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image:
|
|||
return im.convert("L")
|
||||
|
||||
|
||||
def _normalize_palette(im, palette, info):
|
||||
_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.
|
||||
|
@ -526,8 +536,10 @@ def _normalize_palette(im, palette, info):
|
|||
source_palette = bytearray(i // 3 for i in range(768))
|
||||
im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
|
||||
|
||||
used_palette_colors: list[int] | None
|
||||
if palette:
|
||||
used_palette_colors = []
|
||||
assert source_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)
|
||||
|
@ -561,7 +573,7 @@ def _normalize_palette(im, palette, info):
|
|||
def _write_single_frame(
|
||||
im: Image.Image,
|
||||
fp: IO[bytes],
|
||||
palette: bytes | bytearray | list[int] | ImagePalette.ImagePalette,
|
||||
palette: _Palette | None,
|
||||
) -> None:
|
||||
im_out = _normalize_mode(im)
|
||||
for k, v in im_out.info.items():
|
||||
|
@ -585,7 +597,7 @@ def _write_single_frame(
|
|||
|
||||
def _getbbox(
|
||||
base_im: Image.Image, im_frame: Image.Image
|
||||
) -> tuple[Image.Image, tuple[int, int, int, int]]:
|
||||
) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
|
||||
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
|
||||
im_frame = im_frame.convert("RGBA")
|
||||
base_im = base_im.convert("RGBA")
|
||||
|
@ -593,12 +605,20 @@ def _getbbox(
|
|||
return delta, delta.getbbox(alpha_only=False)
|
||||
|
||||
|
||||
def _write_multiple_frames(im, fp, palette):
|
||||
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 = []
|
||||
previous_im = None
|
||||
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", [])):
|
||||
|
@ -624,24 +644,22 @@ def _write_multiple_frames(im, fp, palette):
|
|||
frame_count += 1
|
||||
|
||||
diff_frame = None
|
||||
if im_frames:
|
||||
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"
|
||||
]
|
||||
im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
|
||||
continue
|
||||
if im_frames[-1]["encoderinfo"].get("disposal") == 2:
|
||||
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)
|
||||
background_im.putpalette(im_frames[0]["im"].palette)
|
||||
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:
|
||||
|
@ -687,31 +705,29 @@ def _write_multiple_frames(im, fp, palette):
|
|||
else:
|
||||
bbox = None
|
||||
previous_im = im_frame
|
||||
im_frames.append(
|
||||
{"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo}
|
||||
)
|
||||
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
|
||||
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"]:
|
||||
im_frame = frame_data.im
|
||||
if not frame_data.bbox:
|
||||
# global header
|
||||
for s in _get_global_header(im_frame, frame_data["encoderinfo"]):
|
||||
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
|
||||
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"])
|
||||
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
|
||||
|
||||
|
||||
|
@ -748,7 +764,9 @@ def get_interlace(im: Image.Image) -> int:
|
|||
return interlace
|
||||
|
||||
|
||||
def _write_local_header(fp, im, offset, flags):
|
||||
def _write_local_header(
|
||||
fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
|
||||
) -> None:
|
||||
try:
|
||||
transparency = im.encoderinfo["transparency"]
|
||||
except KeyError:
|
||||
|
@ -849,7 +867,7 @@ def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|||
_FORCE_OPTIMIZE = False
|
||||
|
||||
|
||||
def _get_optimize(im, info):
|
||||
def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
|
||||
"""
|
||||
Palette optimization is a potentially expensive operation.
|
||||
|
||||
|
@ -893,6 +911,7 @@ def _get_optimize(im, info):
|
|||
and current_palette_size > 2
|
||||
):
|
||||
return used_palette_colors
|
||||
return None
|
||||
|
||||
|
||||
def _get_color_table_size(palette_bytes: bytes) -> int:
|
||||
|
@ -933,7 +952,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes:
|
|||
return im.palette.palette if im.palette else b""
|
||||
|
||||
|
||||
def _get_background(im, info_background):
|
||||
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):
|
||||
|
@ -956,7 +978,7 @@ def _get_background(im, info_background):
|
|||
return background
|
||||
|
||||
|
||||
def _get_global_header(im, info):
|
||||
def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
|
||||
"""Return a list of strings representing a GIF header"""
|
||||
|
||||
# Header Block
|
||||
|
@ -1018,7 +1040,12 @@ def _get_global_header(im, info):
|
|||
return header
|
||||
|
||||
|
||||
def _write_frame_data(fp, im_frame, offset, params):
|
||||
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
|
||||
|
||||
|
@ -1038,7 +1065,9 @@ def _write_frame_data(fp, im_frame, offset, params):
|
|||
# Legacy GIF utilities
|
||||
|
||||
|
||||
def getheader(im, palette=None, info=None):
|
||||
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.
|
||||
|
||||
|
@ -1050,11 +1079,11 @@ def getheader(im, palette=None, info=None):
|
|||
:returns: tuple of(list of header items, optimized palette)
|
||||
|
||||
"""
|
||||
used_palette_colors = _get_optimize(im, info)
|
||||
|
||||
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"]
|
||||
|
||||
|
@ -1066,7 +1095,9 @@ def getheader(im, palette=None, info=None):
|
|||
return header, used_palette_colors
|
||||
|
||||
|
||||
def getdata(im, offset=(0, 0), **params):
|
||||
def getdata(
|
||||
im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
|
||||
) -> list[bytes]:
|
||||
"""
|
||||
Legacy Method
|
||||
|
||||
|
@ -1083,12 +1114,23 @@ def getdata(im, offset=(0, 0), **params):
|
|||
:returns: List of bytes containing GIF encoded frame data
|
||||
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
class Collector:
|
||||
class Collector(BytesIO):
|
||||
data = []
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.data.append(data)
|
||||
if sys.version_info >= (3, 12):
|
||||
from collections.abc import Buffer
|
||||
|
||||
def write(self, data: Buffer) -> int:
|
||||
self.data.append(data)
|
||||
return len(data)
|
||||
|
||||
else:
|
||||
|
||||
def write(self, data: Any) -> int:
|
||||
self.data.append(data)
|
||||
return len(data)
|
||||
|
||||
im.load() # make sure raster data is available
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ import warnings
|
|||
from collections.abc import Callable, MutableMapping
|
||||
from enum import IntEnum
|
||||
from types import ModuleType
|
||||
from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast
|
||||
from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast
|
||||
|
||||
# VERSION was removed in Pillow 6.0.0.
|
||||
# PILLOW_VERSION was removed in Pillow 9.0.0.
|
||||
|
@ -1367,7 +1367,7 @@ class Image:
|
|||
"""
|
||||
return ImageMode.getmode(self.mode).bands
|
||||
|
||||
def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]:
|
||||
def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None:
|
||||
"""
|
||||
Calculates the bounding box of the non-zero regions in the
|
||||
image.
|
||||
|
@ -3029,12 +3029,18 @@ def new(
|
|||
color = ImageColor.getcolor(color, mode)
|
||||
|
||||
im = Image()
|
||||
if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]:
|
||||
# RGB or RGBA value for a P image
|
||||
from . import ImagePalette
|
||||
if (
|
||||
mode == "P"
|
||||
and isinstance(color, (list, tuple))
|
||||
and all(isinstance(i, int) for i in color)
|
||||
):
|
||||
color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color))
|
||||
if len(color_ints) == 3 or len(color_ints) == 4:
|
||||
# RGB or RGBA value for a P image
|
||||
from . import ImagePalette
|
||||
|
||||
im.palette = ImagePalette.ImagePalette()
|
||||
color = im.palette.getcolor(color)
|
||||
im.palette = ImagePalette.ImagePalette()
|
||||
color = im.palette.getcolor(color_ints)
|
||||
return im._new(core.fill(mode, size, color))
|
||||
|
||||
|
||||
|
|
|
@ -497,7 +497,7 @@ def expand(
|
|||
color = _color(fill, image.mode)
|
||||
if image.palette:
|
||||
palette = ImagePalette.ImagePalette(palette=image.getpalette())
|
||||
if isinstance(color, tuple):
|
||||
if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
|
||||
color = palette.getcolor(color)
|
||||
else:
|
||||
palette = None
|
||||
|
|
|
@ -18,10 +18,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
from typing import IO, Sequence
|
||||
from typing import IO, TYPE_CHECKING, Sequence
|
||||
|
||||
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Image
|
||||
|
||||
|
||||
class ImagePalette:
|
||||
"""
|
||||
|
@ -128,7 +131,11 @@ class ImagePalette:
|
|||
raise ValueError(msg) from e
|
||||
return index
|
||||
|
||||
def getcolor(self, color, image=None) -> int:
|
||||
def getcolor(
|
||||
self,
|
||||
color: tuple[int, int, int] | tuple[int, int, int, int],
|
||||
image: Image.Image | None = None,
|
||||
) -> int:
|
||||
"""Given an rgb tuple, allocate palette entry.
|
||||
|
||||
.. warning:: This method is experimental.
|
||||
|
@ -163,7 +170,7 @@ class ImagePalette:
|
|||
self.dirty = 1
|
||||
return index
|
||||
else:
|
||||
msg = f"unknown color specifier: {repr(color)}"
|
||||
msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
|
||||
raise ValueError(msg)
|
||||
|
||||
def save(self, fp: str | IO[str]) -> None:
|
||||
|
|
Loading…
Reference in New Issue
Block a user