Pillow/src/PIL/CurImagePlugin.py
2025-05-10 22:48:40 +10:00

238 lines
7.2 KiB
Python

#
# The Python Imaging Library.
# $Id$
#
# Windows Cursor support for PIL
#
# notes:
# uses BmpImagePlugin.py to read the bitmap data.
#
# history:
# 96-05-27 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from io import BytesIO
from typing import IO, NamedTuple
from . import BmpImagePlugin, IcoImagePlugin, Image, ImageFile
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o16le as o16
from ._binary import o32le as o32
#
# --------------------------------------------------------------------
_MAGIC = b"\x00\x00\x02\x00"
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
fp.write(_MAGIC)
bmp = im.encoderinfo.get("bitmap_format", "") == "bmp"
s = im.encoderinfo.get(
"sizes",
[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
)
h = im.encoderinfo.get("hotspots", [(0, 0) for i in range(len(s))])
if len(h) != len(s):
msg = "Number of hotspots must be equal to number of cursor sizes"
raise ValueError(msg)
# sort and remove duplicate sizes
sizes, hotspots = [], []
for size, hotspot in sorted(zip(s, h), key=lambda x: x[0]):
if size not in sizes:
sizes.append(size)
hotspots.append(hotspot)
frames = []
width, height = im.size
for size in sizes:
if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
continue
# TODO: invent a more convenient method for proportional scalings
frame = im.copy()
frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
frames.append(frame)
fp.write(o16(len(frames))) # idCount(2)
offset = fp.tell() + len(frames) * 16
for hotspot, frame in zip(hotspots, frames):
width, height = frame.size
# 0 means 256
fp.write(o8(width if width < 256 else 0)) # bWidth(1)
fp.write(o8(height if height < 256 else 0)) # bHeight(1)
bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
fp.write(o8(colors)) # bColorCount(1)
fp.write(b"\0") # bReserved(1)
fp.write(o16(hotspot[0])) # x_hotspot(2)
fp.write(o16(hotspot[1])) # y_hotspot(2)
image_io = BytesIO()
if bmp:
if bits != 32:
and_mask = Image.new("1", size)
ImageFile._save(
and_mask,
image_io,
[ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
)
else:
frame.alpha = True
frame.save(image_io, "dib")
else:
frame.save(image_io, "png")
image_io.seek(0)
image_bytes = image_io.read()
if bmp:
image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
bytes_len = len(image_bytes)
fp.write(o32(bytes_len)) # dwBytesInRes(4)
fp.write(o32(offset)) # dwImageOffset(4)
current = fp.tell()
fp.seek(offset)
fp.write(image_bytes)
offset = offset + bytes_len
fp.seek(current)
def _accept(prefix: bytes) -> bool:
return prefix.startswith(_MAGIC)
class IconHeader(NamedTuple):
width: int
height: int
nb_color: int
reserved: int
bpp: int
x_hotspot: int
y_hotspot: int
size: int
offset: int
dim: tuple[int, int]
square: int
##
# Image plugin for Windows Cursor files.
class CurFile(IcoImagePlugin.IcoFile):
def __init__(self, buf: IO[bytes]) -> None:
"""
Parse image from file-like object containing cur file data
"""
# check if CUR
s = buf.read(6)
if not _accept(s):
msg = "not a CUR file"
raise SyntaxError(msg)
self.buf = buf
self.entry = []
# Number of items in file
self.nb_items = i16(s, 4)
# Get headers for each item
for _ in range(self.nb_items):
s = buf.read(16)
# See Wikipedia
width = s[0] or 256
height = s[1] or 256
size = i32(s, 8)
square = width * height
# TODO: This needs further investigation. Cursor files do not really
# specify their bpp like ICO's as those bits are used for the y_hotspot.
# For now, bpp is calculated by subtracting the AND mask (equal to number
# of pixels * 1bpp) and dividing by the number of pixels. This seems
# to work well so far.
BITMAP_INFO_HEADER_SIZE = 40
bpp_without_and = ((size - BITMAP_INFO_HEADER_SIZE) * 8) // square
if bpp_without_and != 32:
bpp = ((size - BITMAP_INFO_HEADER_SIZE) * 8 - square) // square
else:
bpp = bpp_without_and
icon_header = IconHeader(
width=width,
height=height,
nb_color=s[2], # No. of colors in image (0 if >=8bpp)
reserved=s[3],
bpp=bpp,
x_hotspot=i16(s, 4),
y_hotspot=i16(s, 6),
size=size,
offset=i32(s, 12),
dim=(width, height),
square=square,
)
self.entry.append(icon_header)
self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
def hotspots(self) -> list[tuple[int, int]]:
return [(h.x_hotspot, h.y_hotspot) for h in self.entry]
class CurImageFile(IcoImagePlugin.IcoImageFile):
"""
PIL read-only image support for Microsoft Windows .cur files.
By default the largest resolution image in the file will be loaded. This
can be changed by altering the 'size' attribute before calling 'load'.
The info dictionary has a key 'sizes' that is a list of the sizes available
in the icon file. It also contains key 'hotspots' that is a list of the
cursor hotspots.
Handles classic, XP and Vista icon formats.
When saving, PNG compression is used. Support for this was only added in
Windows Vista. If you are unable to view the icon in Windows, convert the
image to "RGBA" mode before saving. This is an extension of the IcoImagePlugin.
Raises:
ValueError: The number of sizes and hotspots do not match.
SyntaxError: The file is not a cursor file.
TypeError: There are no cursors contained withing the file.
"""
format = "CUR"
format_description = "Windows Cursor"
def _open(self) -> None:
self.ico = CurFile(self.fp)
self.info["sizes"] = self.ico.sizes()
self.info["hotspots"] = self.ico.hotspots()
if len(self.ico.entry) > 0:
self.size = self.ico.entry[0].dim
else:
msg = "No cursors were found"
raise TypeError(msg)
self.load()
#
# --------------------------------------------------------------------
Image.register_open(CurImageFile.format, CurImageFile, _accept)
Image.register_save(CurImageFile.format, _save)
Image.register_extension(CurImageFile.format, ".cur")