|
| 1 | +""" |
| 2 | +Bitmap read/write operations. |
| 3 | +""" |
| 4 | + |
| 5 | +import pathlib |
| 6 | +from dataclasses import dataclass |
| 7 | +from itertools import islice |
| 8 | +from mmap import mmap, ACCESS_WRITE |
| 9 | +from struct import pack, unpack |
| 10 | +from typing import Any, Union, Iterator |
| 11 | + |
| 12 | + |
| 13 | +class Bitmap: |
| 14 | + """High-level interface to a bitmap file.""" |
| 15 | + |
| 16 | + def __init__(self, path: pathlib.Path) -> None: |
| 17 | + self._file = path.open(mode="r+b") |
| 18 | + self._file_bytes = mmap(self._file.fileno(), 0, access=ACCESS_WRITE) |
| 19 | + self._header = Header.from_bytes(self._file_bytes[:50]) |
| 20 | + |
| 21 | + def __enter__(self) -> "Bitmap": |
| 22 | + return self |
| 23 | + |
| 24 | + def __exit__(self, *args, **kwargs) -> None: |
| 25 | + self._file_bytes.close() |
| 26 | + self._file.close() |
| 27 | + |
| 28 | + def __getattr__(self, name: str) -> Any: |
| 29 | + return getattr(self._header, name) |
| 30 | + |
| 31 | + def __getitem__(self, offset: Union[int, slice]) -> Union[int, bytes]: |
| 32 | + return self._file_bytes[offset] |
| 33 | + |
| 34 | + def __setitem__( |
| 35 | + self, offset: Union[int, slice], value: Union[int, bytes] |
| 36 | + ) -> None: |
| 37 | + self._file_bytes[offset] = value |
| 38 | + |
| 39 | + @property |
| 40 | + def max_bytes(self) -> int: |
| 41 | + """The maximum number of bytes the bitmap can hide.""" |
| 42 | + return self.width * self.height * 3 |
| 43 | + |
| 44 | + @property |
| 45 | + def byte_offsets(self) -> Iterator[int]: |
| 46 | + """Return an iterator over byte offsets (skip the padding).""" |
| 47 | + |
| 48 | + start_index = self.pixels_offset |
| 49 | + end_index = self.pixels_offset + self.pixel_size_bytes |
| 50 | + scanline_bytes = self.pixel_size_bytes // self.height |
| 51 | + |
| 52 | + for scanline in range(start_index, end_index, scanline_bytes): |
| 53 | + yield from range(scanline, scanline + self.width * 3) |
| 54 | + |
| 55 | + @property |
| 56 | + def byte_slices(self) -> Iterator[slice]: |
| 57 | + """Generator iterator of 8-byte long slices.""" |
| 58 | + for byte_index in islice(self.byte_offsets, 0, self.max_bytes, 8): |
| 59 | + yield slice(byte_index, byte_index + 8) |
| 60 | + |
| 61 | + @property |
| 62 | + def reserved_field(self) -> int: |
| 63 | + """Return a little-endian 32-bit unsigned integer.""" |
| 64 | + return unsigned_int(self._file_bytes, 0x06) |
| 65 | + |
| 66 | + @reserved_field.setter |
| 67 | + def reserved_field(self, value: int) -> None: |
| 68 | + """Store a little-endian 32-bit unsigned integer.""" |
| 69 | + self._file_bytes.seek(0x06) |
| 70 | + self._file_bytes.write(pack("<I", value)) |
| 71 | + |
| 72 | + |
| 73 | +@dataclass |
| 74 | +class Header: |
| 75 | + """Bitmap metadata from the file header.""" |
| 76 | + |
| 77 | + signature: bytes |
| 78 | + file_size_bytes: int |
| 79 | + pixel_size_bytes: int |
| 80 | + pixels_offset: int |
| 81 | + width: int |
| 82 | + height: int |
| 83 | + bit_depth: int |
| 84 | + compressed: bool |
| 85 | + has_palette: bool |
| 86 | + |
| 87 | + def __post_init__(self): |
| 88 | + assert self.signature == b"BM", "Unknown file signature" |
| 89 | + assert not self.compressed, "Compression unsupported" |
| 90 | + assert not self.has_palette, "Color palette unsupported" |
| 91 | + assert self.bit_depth == 24, "Only 24-bit depth supported" |
| 92 | + |
| 93 | + @staticmethod |
| 94 | + def from_bytes(data: bytes) -> "Header": |
| 95 | + """Factory method to deserialize the header from bytes.""" |
| 96 | + return Header( |
| 97 | + signature=data[0x00:2], |
| 98 | + file_size_bytes=unsigned_int(data, 0x02), |
| 99 | + pixels_offset=unsigned_int(data, 0x0A), |
| 100 | + width=unsigned_int(data, 0x12), |
| 101 | + height=unsigned_int(data, 0x16), |
| 102 | + bit_depth=unsigned_short(data, 0x1C), |
| 103 | + compressed=unsigned_int(data, 0x1E) != 0, |
| 104 | + has_palette=unsigned_int(data, 0x2E) != 0, |
| 105 | + pixel_size_bytes=unsigned_int(data, 0x22), |
| 106 | + ) |
| 107 | + |
| 108 | + |
| 109 | +def unsigned_int(data: Union[bytes, mmap], offset: int) -> int: |
| 110 | + """Read a little-endian 32-bit unsigned integer.""" |
| 111 | + return unpack("<I", data[offset : offset + 4])[0] |
| 112 | + |
| 113 | + |
| 114 | +def unsigned_short(data: Union[bytes, mmap], offset: int) -> int: |
| 115 | + """Read a little-endian 16-bit unsigned integer.""" |
| 116 | + return unpack("<H", data[offset : offset + 2])[0] |
0 commit comments