def read(cls, cur_file: BinaryIO) -> AnimatedCursor: """ Read a windows .ani file from disk to an AnimatedCursor. :param cur_file: The file buffer pointer to the windows .ani data. :return: An AnimatedCursor object storing all cursor info. """ magic_header = cur_file.read(12) if (not cls.check(magic_header)): raise SyntaxError("Not a .ani file!") ani_data: Dict[str, Any] = {"header": None} for chunk_id, chunk_len, chunk_data in read_chunks( cur_file, {b"fram"}, {b"LIST"}): if (chunk_id in cls.CHUNKS): cls.CHUNKS[chunk_id](ani_data["header"], chunk_data, ani_data) ani_cur = AnimatedCursor() for idx, rate in zip(ani_data["seq"], ani_data["rate"]): # We have to convert the rate to milliseconds. Normally stored in jiffies(1/60ths of a second) ani_cur.append((ani_data["list"][idx], int((rate * 1000) / 60))) return ani_cur
def __unify_frames( cls, cur: AnimatedCursor ) -> Tuple[int, float, Vec2D, Vec2D, List[Image.Image]]: """ Private method, takes a cursor and converts it into a vertically tiled image with a single delay and single hotspot. Uses some hacky shenanigans to do this. :)...... :param cur: An AnimatedCursor :return: A tuple with: - Integer: Number of frames - Float: Delay in seconds for each frame. - Vec2D: Hotspot location. - Vec2D: Size of image - List[PIL.Image]: Image representations, at 1x, 2x, and 5x... """ cur = cur.copy() delays = np.array([delay for sub_cur, delay in cur]) cumulative_delay = np.cumsum(delays) half_avg = int(np.mean(delays) / 4) gcd_of_em = np.gcd.reduce(delays) unified_delay = max(gcd_of_em, half_avg) num_frames = cumulative_delay[-1] // unified_delay cur.restrict_to_sizes(cls.CURSOR_EXPORT_SIZES) new_images = [ Image.new("RGBA", (size[0] * 2, (size[1] * 2) * num_frames), (0, 0, 0, 0)) for size in cls.CURSOR_EXPORT_SIZES ] next_ani_frame = 1 for current_out_frame in range(num_frames): time_in = current_out_frame * unified_delay while ((next_ani_frame < len(cumulative_delay)) and (time_in >= cumulative_delay[next_ani_frame])): next_ani_frame += 1 for i, img in enumerate(new_images): current_size = cls.CURSOR_EXPORT_SIZES[i] current_cur = cur[next_ani_frame - 1][0][current_size] x_off = current_size[0] - current_cur.hotspot[0] y_off = ((current_size[1] * 2) * current_out_frame) + ( current_size[1] - current_cur.hotspot[1]) img.paste(current_cur.image, (x_off, y_off)) final_hotspot = (cls.CURSOR_EXPORT_SIZES[0][0], cls.CURSOR_EXPORT_SIZES[0][1]) final_dims = (cls.CURSOR_EXPORT_SIZES[0][0] * 2, cls.CURSOR_EXPORT_SIZES[0][1] * 2) return (int(num_frames), float(unified_delay) / 1000, final_hotspot, final_dims, new_images)
def load_cursor_from_image(file: BinaryIO) -> AnimatedCursor: """ Load a cursor from an image. Image is expected to be square. If it is not square, this method will slide horizontally across the image using a height * height square, loading each following square as next frame of the cursor with a delay of 100 and hotspots of (0, 0). Note this method supports all formats supported by the PIL or pillow Image library. If the image passed is an animated image format like .gif or .apng, this method will avoid loading in horizontal square tiles and will rather load in each frame of the image as each frame of the cursor. :param file: The file handler pointing to the image data. :return: An AnimatedCursor object, representing an animated cursor. Static cursors will only have 1 frame. """ image: Image.Image = Image.open(file) if (hasattr(image, "is_animated") and (image.is_animated)): # If this is an animated file, load in each frame as the frames of the cursor (Ex, ".gif") min_dim = min(image.size) # We fit the image to a square... images_durations = [(ImageOps.fit(image, (min_dim, min_dim)), frame.info.get("duration", 100)) for frame in ImageSequence.Iterator(image)] else: # Separate all frames (Assumed to be stored horizontally) height = image.size[1] num_frames = image.size[0] // height if (num_frames == 0): raise ValueError( "Image width is smaller then height so this will load as a 0 frame cursor!!!" ) images_durations = [(image.crop( (i * height, 0, i * height + height, height)), 100) for i in range(num_frames)] # Now convert images into the cursors, resizing them to match all the default sizes... final_cursor = AnimatedCursor() for img, delay in images_durations: frame = Cursor() for size in DEFAULT_SIZES: frame.add(CursorIcon(img.resize(size, Image.LANCZOS), 0, 0)) final_cursor.append((frame, delay)) return final_cursor
def _make_image(cursor: AnimatedCursor) -> Image: """ PRIVATE METHOD: Make an image from a cursor, representing all of it's frames. Used by save_project to make project art files when original files can't be found and copied over, as the cursor was loaded from the clip board as an image or dragged and dropped from the internet... :param cursor: The cursor to convert to a tiled horizontal image. :return: An picture with frames stored horizontally... """ cursor.normalize([(128, 128)]) im = Image.new("RGBA", (128 * len(cursor), 128), (0, 0, 0, 0)) for i, (cursor, delay) in enumerate(cursor): im.paste(cursor[(128, 128)].image, (128 * i, 0)) return im
def load_cursor_from_cursor(file: BinaryIO) -> AnimatedCursor: """ Loads a cursor from one of the supported cursor formats implemented in this library. Normalizes sizes and removes non-square versions of the cursor... :param file: The file handler pointing to the SVG data. :return: An AnimatedCursor object, representing an animated cursor. Static cursors will only have 1 frame. """ # Currently supported cursor formats... ani_cur_readers = format_core.AnimatedCursorStorageFormat.__subclasses__() cur_readers = format_core.CursorStorageFormat.__subclasses__() file.seek(0) magic = file.read(12) file.seek(0) for reader in ani_cur_readers: if (reader.check(magic)): ani_cur = reader.read(file) ani_cur.normalize(DEFAULT_SIZES) ani_cur.remove_non_square_sizes() return ani_cur for reader in cur_readers: if (reader.check(magic)): ani_cur = AnimatedCursor([reader.read(file)], [100]) ani_cur.normalize(DEFAULT_SIZES) ani_cur.remove_non_square_sizes() return ani_cur raise ValueError("Unsupported cursor format.")
def read(cls, cur_file: BinaryIO) -> AnimatedCursor: """ Read an xcur or X-Org cursor file from the specified file buffer. :param cur_file: The file buffer with xcursor data. :return: An AnimatedCursor object, non-animated cursors will contain only 1 frame. """ magic_data = cur_file.read(4) cls._assert(cls.check(magic_data), "Not a XCursor File!!!") header_size = to_int(cur_file.read(4)) cls._assert(header_size == cls.HEADER_SIZE, f"Header size is not {cls.HEADER_SIZE}!") version = to_int(cur_file.read(4)) # Number of cursors... num_toc = to_int(cur_file.read(4)) # Used to store cursor offsets per size... nominal_sizes = {} for i in range(num_toc): main_type = to_int(cur_file.read(4)) if (main_type == cls.CURSOR_TYPE): nominal_size = to_int(cur_file.read(4)) offset = to_int(cur_file.read(4)) if (nominal_size not in nominal_sizes): nominal_sizes[nominal_size] = [offset] else: nominal_sizes[nominal_size].append(offset) max_len = max(len(nominal_sizes[size]) for size in nominal_sizes) cursors = [] delays = [] for i in range(max_len): cursor = Cursor() sub_delays = [] for size, offsets in nominal_sizes.items(): if (i < len(offsets)): img, x_hot, y_hot, delay = cls._read_chunk( offsets[i], cur_file, size) cursor.add(CursorIcon(img, x_hot, y_hot)) sub_delays.append(delay) cursors.append(cursor) delays.append(max(sub_delays)) return AnimatedCursor(cursors, delays)
def __init__(self, parent=None, cursor: AnimatedCursor=None, frame=0, *args, **kwargs): super().__init__(parent, *args, **kwargs) self._frame = 0 self._frame_img = None self._pressed = False if((cursor is None) or (len(cursor) == 0)): self._cursor = AnimatedCursor( [Cursor([CursorIcon(Image.fromarray(np.zeros(self.VIEW_SIZE + (4,), dtype=np.uint8)), 0, 0)])], [100] ) else: self._cursor = cursor self._cursor.normalize([self.VIEW_SIZE]) self.__painter = QtGui.QPainter() self.frame = frame self.setCursor(QtGui.Qt.CrossCursor) self.setMinimumSize(QtCore.QSize(*self.VIEW_SIZE)) self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) self.resize(self.minimumSize())
def write(cls, cursor: AnimatedCursor, out: BinaryIO): """ Write an AnimatedCursor to the specified file in the X-Org cursor format. :param cursor: The AnimatedCursor object to write. :param out: The file buffer to write the new X-Org Cursor data to. """ cursor = cursor.copy() cursor.normalize() if (len(cursor) == 0): return num_curs = sum(len(l) for l, delay in cursor) out.write(cls.MAGIC) out.write(to_bytes(cls.HEADER_SIZE, 4)) out.write(to_bytes(cls.VERSION, 4)) out.write(to_bytes(num_curs, 4)) # The initial offset... offset = (num_curs * 12 + cls.HEADER_SIZE) sorted_sizes = sorted(cursor[0][0]) # Write the Table of contents [type, subtype(size), offset] for size in sorted_sizes: for sub_cur, delay in cursor: out.write(to_bytes(cls.CURSOR_TYPE, 4)) out.write(to_bytes(int(size[0] * cls.SIZE_SCALING_FACTOR), 4)) out.write(to_bytes(offset, 4)) offset += cls.IMG_CHUNK_H_SIZE + (size[0] * size[1] * 4) # Write the actual images... for size in sorted_sizes: for sub_cur, delay in cursor: cls._write_chunk(out, sub_cur[size], delay)
def load_cursor_from_svg(file: BinaryIO) -> AnimatedCursor: """ Load a cursor from an SVG. SVG is expected to be square. If it is not square, this method will slide horizontally across the SVG using a height * height square, loading each following square as next frame of the cursor with a delay of 100 and hotspots of (0, 0). Note this method uses CairoSVG library to load and render SVGs of various sizes to bitmaps. For info on supported SVG features, look at the docs for the CairoSVG library. :param file: The file handler pointing to the SVG data. :return: An AnimatedCursor object, representing an animated cursor. Static cursors will only have 1 frame. """ # Convert SVG in memory to PNG, and read that in with PIL to get the default size of the SVG... mem_png = BytesIO() cairosvg.svg2png(file_obj=file, write_to=mem_png) file.seek(0) size_ref_img = Image.open(mem_png) # Compute height to width ratio an the number of frame(Assumes they are stored horizontally)... h_to_w_multiplier = size_ref_img.size[0] / size_ref_img.size[1] num_frames = int(h_to_w_multiplier) if (num_frames == 0): raise ValueError( "Image width is smaller then height so this will load as a 0 frame cursor!!!" ) # Build empty animated cursor to start stashing frames in... ani_cur = AnimatedCursor([Cursor() for i in range(num_frames)], [100] * num_frames) for sizes in DEFAULT_SIZES: # For each default size, resize svg to it and add all frames to the AnimatedCursor object... image = BytesIO() cairosvg.svg2png(file_obj=file, write_to=image, output_height=sizes[1], output_width=int(sizes[1] * h_to_w_multiplier)) file.seek(0) image = Image.open(image) height = image.size[1] for i in range(num_frames): ani_cur[i][0].add( CursorIcon( image.crop((i * height, 0, i * height + height, height)), 0, 0)) return ani_cur
class CursorHotspotWidget(QtWidgets.QWidget): VIEW_SIZE = (64, 64) userHotspotChange = QtCore.Signal((int, int)) def __init__(self, parent=None, cursor: AnimatedCursor=None, frame=0, *args, **kwargs): super().__init__(parent, *args, **kwargs) self._frame = 0 self._frame_img = None self._pressed = False if((cursor is None) or (len(cursor) == 0)): self._cursor = AnimatedCursor( [Cursor([CursorIcon(Image.fromarray(np.zeros(self.VIEW_SIZE + (4,), dtype=np.uint8)), 0, 0)])], [100] ) else: self._cursor = cursor self._cursor.normalize([self.VIEW_SIZE]) self.__painter = QtGui.QPainter() self.frame = frame self.setCursor(QtGui.Qt.CrossCursor) self.setMinimumSize(QtCore.QSize(*self.VIEW_SIZE)) self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) self.resize(self.minimumSize()) def paintEvent(self, event: QtGui.QPaintEvent): self.__painter.begin(self) self.__painter.setPen(QtGui.QColor("black")) self.__painter.setBrush(QtGui.QColor("red")) self.__painter.drawPixmap(0, 0, self._frame_img) hotspot = QtCore.QPoint(*self.hotspot) self.__painter.setPen(QtGui.QColor(0, 0, 0, 150)) self.__painter.setBrush(QtGui.QColor(255, 0, 0, 100)) self.__painter.drawEllipse(hotspot, 4, 4) self.__painter.setPen(QtGui.QColor(0, 0, 255, 255)) self.__painter.setBrush(QtGui.QColor(0, 0, 255, 255)) self.__painter.drawEllipse(hotspot, 1, 1) self.__painter.end() def sizeHint(self) -> QtCore.QSize: return QtCore.QSize(*self.VIEW_SIZE) def mousePressEvent(self, event: QtGui.QMouseEvent): self._pressed = True def mouseMoveEvent(self, event: QtGui.QMouseEvent): if(self._pressed): x, y = event.x(), event.y() self.hotspot = x, y self.userHotspotChange.emit(x, y) def mouseReleaseEvent(self, event: QtGui.QMouseEvent): if(self._pressed): self.mouseMoveEvent(event) self._pressed = False @property def frame(self) -> int: return self._frame @frame.setter def frame(self, value: int): if(0 <= value < len(self._cursor)): self._frame = value self._frame_img = QtGui.QPixmap(ImageQt.ImageQt(self._cursor[self._frame][0][self.VIEW_SIZE].image)) self.update() else: raise ValueError(f"The frame must land within length of the animated cursor!") @property def hotspot(self) -> Tuple[int, int]: return self._cursor[self._frame][0][self.VIEW_SIZE].hotspot @hotspot.setter def hotspot(self, value: Tuple[int, int]): if(not (isinstance(value, Tuple) and len(value) == 2)): raise ValueError("Not a coordinate pair!") value = min(max(0, value[0]), self.VIEW_SIZE[0] - 1), min(max(0, value[1]), self.VIEW_SIZE[1] - 1) for size in self._cursor[self._frame][0]: x_rat, y_rat = size[0] / self.VIEW_SIZE[0], size[1] / self.VIEW_SIZE[1] x_hot, y_hot = int(value[0] * x_rat), int(value[1] * y_rat) self._cursor[self._frame][0][size].hotspot = x_hot, y_hot self.update() @property def current_cursor(self) -> AnimatedCursor: return self._cursor
def write(cls, cursor: AnimatedCursor, out: BinaryIO): """ Write an AnimatedCursor to the specified file in the windows .ani format. :param cursor: The AnimatedCursor object to write. :param out: The file buffer to write the new .ani data to. """ # Normalize the cursor sizes... cursor = copy.deepcopy(cursor) cursor.normalize(list(cls.DEF_CURSOR_SIZES)) cursor.remove_non_square_sizes() for cur, delay in cursor: for size in list(cur): if (size not in cls.DEF_CURSOR_SIZES): del cur[size] # Write the magic... out.write(cls.RIFF_MAGIC) # We will deal with writing the length of the entire file later... out.write(b"\0\0\0\0") out.write(cls.ACON_MAGIC) # Write the header... header = bytearray(36) # We write the header length twice for some dumb reason... header[0:4] = to_bytes(36, 4) # Header length... header[4:8] = to_bytes(len(cursor), 4) # Number of frames header[8:12] = to_bytes(len(cursor), 4) # Number of steps # Ignore width, height, and bits per pixel... # The number of planes should always be 1.... header[24:28] = to_bytes(1, 4) header[28:32] = to_bytes(10, 4) # We just pass 10 as the default delay... header[32:36] = to_bytes( 1, 4 ) # The flags, last flag is flipped which specifies data is stored in .cur write_chunk(out, b"anih", header) # Write the LIST of icons... list_data = bytearray(b"fram") delay_data = bytearray() for sub_cursor, delay in cursor: # Writing a single cursor to the list... mem_stream = BytesIO() CurFormat.write(sub_cursor, mem_stream) # We write these chunks manually to avoid wasting a ton of lines of code, as using "write_chunks" ends up # being just as complicated... cur_data = mem_stream.getvalue() list_data.extend(b"icon") list_data.extend(to_bytes(len(cur_data), 4)) list_data.extend(cur_data) # Writing the delay to the rate chunk delay_data.extend(to_bytes(round((delay * 60) / 1000), 4)) # Now that we have gathered the data actually write the chunks... write_chunk(out, b"LIST", list_data) write_chunk(out, b"rate", delay_data) # Now we are to the end, get the length of the file and write it as the RIFF chunk length... entire_file_len = out.tell() - 8 out.seek(4) out.write(to_bytes(entire_file_len, 4))