diff --git a/src/AEPi/codec.py b/src/AEPi/codec.py index b78e2e7..c2d798c 100644 --- a/src/AEPi/codec.py +++ b/src/AEPi/codec.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from io import BytesIO from typing import Dict, Optional, Type, TypeVar, Iterable from PIL.Image import Image @@ -25,13 +24,17 @@ def compress(cls, im: Image, format: CompressionFormat, quality: Optional[Compre @classmethod @abstractmethod - def decompress(cls, fp: BytesIO, format: CompressionFormat) -> Image: + def decompress(cls, fp: bytes, format: CompressionFormat, width: int, height: int, quality: Optional[CompressionQuality]) -> Image: """Decompress a `format`-compressed BGRA image into a BGRA Image. :param fp: The compressed image to decompress - :type im: BytesIO + :type im: bytes :param format: The compression format :type format: CompressionFormat + :param width: The width of the image + :type width: int + :param height: The height of the image + :type height: int :return: `fp`, decompressed into a BGRA image :rtype: Image """ diff --git a/src/AEPi/codecs/EtcPakCodec.py b/src/AEPi/codecs/EtcPakCodec.py index 5d523cf..ad6dac3 100644 --- a/src/AEPi/codecs/EtcPakCodec.py +++ b/src/AEPi/codecs/EtcPakCodec.py @@ -1,4 +1,5 @@ from io import BytesIO +from typing import Optional from PIL.Image import Image from ..codec import ImageCodecAdaptor, supportsFormats from ..constants import CompressionFormat, CompressionQuality @@ -15,7 +16,7 @@ ]) class EtcPakCodec(ImageCodecAdaptor): @classmethod - def compress(cls, im: Image, format: CompressionFormat, quality: CompressionQuality) -> bytes: + def compress(cls, im: Image, format: CompressionFormat, quality: Optional[CompressionQuality]) -> bytes: if format is CompressionFormat.DXT5: if im.mode != "RGBA": im = im.convert("RGBA") @@ -30,6 +31,6 @@ def compress(cls, im: Image, format: CompressionFormat, quality: CompressionQual @classmethod - def decompress(cls, fp: BytesIO, format: CompressionFormat, quality: CompressionQuality) -> Image: + def decompress(cls, fp: bytes, format: CompressionFormat, width: int, height: int, quality: Optional[CompressionQuality]) -> Image: raise NotImplementedError(f"Codec {EtcPakCodec.__name__} is not capable of decompression") \ No newline at end of file diff --git a/src/AEPi/constants.py b/src/AEPi/constants.py index fe70426..d98479a 100644 --- a/src/AEPi/constants.py +++ b/src/AEPi/constants.py @@ -1,5 +1,6 @@ from enum import Enum from typing import Literal +from AEPi.lib.binaryio import Endianness class CompressionFormat(Enum): """Compression formats. @@ -27,5 +28,5 @@ def isCompressed(self): ) FILE_TYPE_HEADER = b"AEimage\x00" -ENDIANNESS = "<" -CompressionQuality = Literal[1, 2, 3] \ No newline at end of file +ENDIANNESS = Endianness("little", "<") +CompressionQuality = Literal[1, 2, 3] diff --git a/src/AEPi/image/AEI.py b/src/AEPi/image/AEI.py index f0c106c..c980307 100644 --- a/src/AEPi/image/AEI.py +++ b/src/AEPi/image/AEI.py @@ -2,10 +2,11 @@ from typing import BinaryIO from os import PathLike from types import TracebackType -from typing import List, Optional, Set, Tuple, Type, TypeVar, Union, overload +from typing import Any, List, Optional, Set, Tuple, Type, TypeVar, Union, cast, overload from PIL import Image -from ..lib import binaryio, imageOps +from ..lib import imageOps +from ..lib.binaryio import uint8, uint16, uint32, readUInt8, readUInt16, readUInt32 from ..constants import CompressionFormat, FILE_TYPE_HEADER, ENDIANNESS, CompressionQuality from .. import codec @@ -17,11 +18,12 @@ class AEI: """An Abyss Engine Image file. Contains a set of textures, each with an image and coordinates. Each texture must fall within the bounds of the AEI shape. - The AEI shape is mutable. - `format` and `quality` can be set in the constructor, or on call of `AEI.write`. + The AEI shape is mutable, through the `shape` property. + The coordinate origin (0, 0) is the top-left of the AEI. An AEI can be constructed either with its dimensions, or with an image. If an image is used, the AEI is created with a copy of the image. + `format` and `quality` can be set in the constructor, or on call of `AEI.write`. Use the `addTexture` and `removeTexture` helper methods for texture management. @@ -271,7 +273,7 @@ def getTexture(self, val1: Union[Texture, int], y: Optional[int] = None, width: @classmethod - def read(cls, fp: Union[str, PathLike, io.BytesIO]) -> "AEI": + def read(cls, fp: Union[str, PathLike[Any], io.BytesIO]) -> "AEI": """Read an AEI file from bytes, or a file. `fp` can be a path to a file, or an in-memory buffer containing the contents of an encoded AEI file, including metadata. @@ -280,11 +282,65 @@ def read(cls, fp: Union[str, PathLike, io.BytesIO]) -> "AEI": :return: A new AEI file object, containing the decoded contents of `fp` :rtype: AEI """ - # if tempFp := (not isinstance(fp, io.BytesIO)): - # fp = open(fp, "rb") - raise NotImplementedError() - # if tempFp: - # fp.close() + if isinstance(fp, io.StringIO): + raise ValueError("fp must be of binary type, not StringIO") + + file: Union[io.BufferedReader, io.BytesIO] + + if tempFp := (not isinstance(fp, io.BytesIO)): + file = open(fp, "rb") + elif isinstance(fp, (str, PathLike)): + file = open(fp, mode="rb") + else: + file = fp + + try: + bFileType = file.read(len(FILE_TYPE_HEADER)) + if bFileType != FILE_TYPE_HEADER: + raise ValueError(f"Given file is of unknown type '{str(bFileType, encoding='utf-8')}' expected '{str(FILE_TYPE_HEADER, encoding='utf-8')}'") + + formatId = readUInt8(file, ENDIANNESS) + format = CompressionFormat(formatId) + imageCodec = codec.decompressorFor(format) + + width = readUInt16(file, ENDIANNESS) + height = readUInt16(file, ENDIANNESS) + numTextures = readUInt16(file, ENDIANNESS) + + textures: List[Texture] = [] + for _ in range(numTextures): + texX = readUInt16(file, ENDIANNESS) + texY = readUInt16(file, ENDIANNESS) + texWidth = readUInt16(file, ENDIANNESS) + texHeight = readUInt16(file, ENDIANNESS) + textures.append(Texture(texX, texY, texWidth, texHeight)) + + if format.isCompressed: + imageLength = readUInt32(file, ENDIANNESS) + else: + imageLength = 4 * width * height + + compressed = file.read(imageLength) + + symbolGroups = readUInt16(file, ENDIANNESS) + + if symbolGroups > 0: + raise ValueError("AEIs with symbols are not yet supported") + + bQuality = readUInt8(file, ENDIANNESS, None) + quality = cast(Optional[CompressionQuality], bQuality) + + decompressed = imageCodec.decompress(compressed, format, width, height, quality) + + finally: + if tempFp: + file.close() + + aei = AEI(decompressed, format=format, quality=quality) + for tex in textures: + aei.addTexture(tex) + + return aei def write(self, fp: Optional[BinaryIO] = None, format: Optional[CompressionFormat] = None, quality: Optional[CompressionQuality] = None) -> BinaryIO: @@ -323,14 +379,15 @@ def write(self, fp: Optional[BinaryIO] = None, format: Optional[CompressionForma return fp +#region write-util def _writeHeaderMeta(self, fp: BinaryIO, format: CompressionFormat): fp.write(FILE_TYPE_HEADER) - fp.write(binaryio.uint8(format.value, ENDIANNESS)) + fp.write(uint8(format.value, ENDIANNESS)) def writeUInt16(*values: int): for v in values: - fp.write(binaryio.uint16(v, ENDIANNESS)) + fp.write(uint16(v, ENDIANNESS)) # AEI dimensions and texture count writeUInt16( @@ -357,21 +414,22 @@ def _writeImageContent(self, fp: BinaryIO, format: CompressionFormat, quality: O # image length only appears in compressed AEIs if format.isCompressed: - fp.write(binaryio.uint32(len(compressed), ENDIANNESS)) + fp.write(uint32(len(compressed), ENDIANNESS)) fp.write(compressed) def _writeSymbols(self, fp: BinaryIO): #TODO: Unimplemented - fp.write(binaryio.uint16(0, ENDIANNESS)) # number of symbol groups + fp.write(uint16(0, ENDIANNESS)) # number of symbol groups ... def _writeFooterMeta(self, fp: BinaryIO, quality: Optional[CompressionQuality]): if quality is not None: - fp.write(binaryio.uint8(quality, ENDIANNESS)) + fp.write(uint8(quality, ENDIANNESS)) +#endregion write-util def close(self): """Close the underlying image. diff --git a/src/AEPi/image/texture.py b/src/AEPi/image/texture.py index 993adb3..465f693 100644 --- a/src/AEPi/image/texture.py +++ b/src/AEPi/image/texture.py @@ -1,6 +1,49 @@ +from typing import Tuple + + class Texture: def __init__(self, x: int, y: int, width: int, height: int) -> None: self.x = x self.y = y self.width = width self.height = height + + + @property + def shape(self) -> Tuple[int, int]: + """The shape of the texture region, given as a (width, height) tuple. + + :return: The shape of the texture region, as a (width, height) tuple + :rtype: Tuple[int, int] + """ + return (self.width, self.height) + + + @shape.setter + def shape(self, value: Tuple[int, int]) -> None: + """Set the shape of the texture region. + + :param value: The shape of the texture region, as a (width, height) tuple + :type value: Tuple[int, int] + """ + (self.width, self.height) = value + + + @property + def position(self) -> Tuple[int, int]: + """The coordinates of the texture region relative to the AEI origin, given as an (x, y) tuple. + + :return: The coordinates of the texture region, as an (x, y) tuple + :rtype: Tuple[int, int] + """ + return (self.x, self.y) + + + @position.setter + def position(self, value: Tuple[int, int]) -> None: + """Set the coordinates of the texture region relative to the AEI origin. + + :param value: The coordinates of the texture region, as an (x, y) tuple + :type value: Tuple[int, int] + """ + (self.x, self.y) = value diff --git a/src/AEPi/lib/binaryio.py b/src/AEPi/lib/binaryio.py index 81a9fd7..96bc6c6 100644 --- a/src/AEPi/lib/binaryio.py +++ b/src/AEPi/lib/binaryio.py @@ -1,7 +1,12 @@ import struct -from typing import Literal +from typing import Literal, NamedTuple, BinaryIO, Optional, TypeVar, Union -Endianness = Literal["<", ">"] +ShortEndianness = Literal["<", ">"] +NameEndianness = Literal["little", "big"] + +class Endianness(NamedTuple): + name: NameEndianness + short: ShortEndianness def uint8(x: int, endianness: Endianness) -> bytes: @@ -14,7 +19,7 @@ def uint8(x: int, endianness: Endianness) -> bytes: :return: `x` in C uint8-representation :rtype: bytes """ - return struct.pack(endianness + "B", x) + return struct.pack(endianness.short + "B", x) def uint16(x: int, endianness: Endianness) -> bytes: @@ -27,7 +32,7 @@ def uint16(x: int, endianness: Endianness) -> bytes: :return: `x` in C uint16-representation :rtype: bytes """ - return struct.pack(endianness + "H", x) + return struct.pack(endianness.short + "H", x) def uint32(x: int, endianness: Endianness) -> bytes: @@ -40,4 +45,60 @@ def uint32(x: int, endianness: Endianness) -> bytes: :return: `x` in C uint32-representation :rtype: bytes """ - return struct.pack(endianness + "I", x) + return struct.pack(endianness.short + "I", x) + + +TDefault = TypeVar("TDefault", bound=Optional[int]) + +def intFromBytes(b: bytes, endianness: Endianness, default: TDefault = 0) -> Union[int, TDefault]: + """Interpret `b` as a python integer. + + :param b: The bytes to interpret + :type b: bytes + :param endianness: The bit-endianness + :type endianness: Endianness + :return: `b` interpreted as a python int + :rtype: bytes + """ + if b == b'': + return default + return int.from_bytes(b, byteorder=endianness.name) + + +def readUInt8(fp: BinaryIO, endianness: Endianness, default: TDefault = 0) -> Union[int, TDefault]: + """Read a C unsigned char into a python integer. + + :param x: The binary stream from which to read + :type x: BinaryIO + :param endianness: The bit-endianness + :type endianness: Endianness + :return: The next 1 byte from `fp` interpreted as a C uint8 + :rtype: bytes + """ + return intFromBytes(fp.read(1), endianness, default) + + +def readUInt16(fp: BinaryIO, endianness: Endianness, default: TDefault = 0) -> Union[int, TDefault]: + """Read a C unsigned short into a python integer. + + :param x: The binary stream from which to read + :type x: BinaryIO + :param endianness: The bit-endianness + :type endianness: Endianness + :return: The next 2 bytes from `fp` interpreted as a C uint16 + :rtype: bytes + """ + return intFromBytes(fp.read(2), endianness, default) + + +def readUInt32(fp: BinaryIO, endianness: Endianness, default: TDefault = 0) -> Union[int, TDefault]: + """Read a C unsigned int into a python integer. + + :param x: The binary stream from which to read + :type x: BinaryIO + :param endianness: The bit-endianness + :type endianness: Endianness + :return: The next 4 bytes from `fp` interpreted as a C uint32 + :rtype: bytes + """ + return intFromBytes(fp.read(4), endianness, default) diff --git a/src/tests/image/test_AEI.py b/src/tests/image/test_AEI.py index 9c7c142..436fb3b 100644 --- a/src/tests/image/test_AEI.py +++ b/src/tests/image/test_AEI.py @@ -1,7 +1,7 @@ from io import BytesIO from PIL.Image import Image -from AEPi import AEI, Texture, CompressionFormat, CompressionQuality +from AEPi import AEI, Texture, CompressionFormat from AEPi.codec import ImageCodecAdaptor, supportsFormats import pytest from PIL import Image @@ -41,7 +41,9 @@ def compress(cls, im, format, quality): return COMPRESSED @classmethod - def decompress(cls, fp, format): + def decompress(cls, fp, format, width, height, quality): + if USE_SMILEY: + return smileyImage() return DECOMPRESSED #region dimensions @@ -122,6 +124,41 @@ def test_getHeight_getsHeight(): assert aei.height == 5 #endregion dimensions +#region aei files +#region read + + +def test_read_readsImage(): + with AEI.read(PIXEL_AEI_PATH) as aei: + assert aei.width == DECOMPRESSED.width + assert aei.height == DECOMPRESSED.height + + for x in range(DECOMPRESSED.width): + for y in range(DECOMPRESSED.height): + assert aei._image.getpixel((x, y)) == DECOMPRESSED.getpixel((x, y)) + + +def test_read_readsTextures(): + with AEI.read(PIXEL_AEI_PATH) as aei: + assert aei.textures[0].x == 0 + assert aei.textures[0].y == 0 + assert aei.textures[0].width == DECOMPRESSED.width + assert aei.textures[0].height == DECOMPRESSED.height + + +def test_read_twoTextures_isCorrect(): + global USE_SMILEY + USE_SMILEY = True + with AEI.read(SMILEY_AEI_2TEXTURES_PATH) as aei: + assert len(aei.textures) == 2 + assert aei.textures[0].shape == (8, 8) + assert aei.textures[0].position == (0, 0) + assert aei.textures[1].shape == (8, 8) + assert aei.textures[1].position == (8, 8) + USE_SMILEY = False + + +#endregion read #region write def test_write_isCorrect(): @@ -148,6 +185,7 @@ def test_write_twoTextures_isCorrect(): USE_SMILEY = False #endregion write +#endregion aei files #region textures def test_addTexture_addsTexture(): diff --git a/src/tests/test_codec.py b/src/tests/test_codec.py index b8ea011..cfa6edf 100644 --- a/src/tests/test_codec.py +++ b/src/tests/test_codec.py @@ -2,33 +2,34 @@ from AEPi.codec import ImageCodecAdaptor, supportsFormats, compressorFor, decompressorFor from AEPi.constants import CompressionFormat import pytest +from PIL.Image import Image @supportsFormats(compresses=[CompressionFormat.DXT5]) class Dxt5Compressor(ImageCodecAdaptor): @classmethod - def compress(cls, im, format, quality): pass + def compress(cls, im, format, quality): return b'' @classmethod - def decompress(cls, fp, format): pass + def decompress(cls, fp, format, width, height, quality): return Image() @supportsFormats(decompresses=[CompressionFormat.ETC1]) class Etc1Decompressor(ImageCodecAdaptor): @classmethod - def compress(cls, im, format, quality): pass + def compress(cls, im, format, quality): return b'' @classmethod - def decompress(cls, fp, format): pass + def decompress(cls, fp, format, width, height, quality): return Image() @supportsFormats(both=[CompressionFormat.PVRTCI2A]) class PvrCodec(ImageCodecAdaptor): @classmethod - def compress(cls, im, format, quality): pass + def compress(cls, im, format, quality): return b'' @classmethod - def decompress(cls, fp, format): pass + def decompress(cls, fp, format, width, height, quality): return Image() @pytest.mark.parametrize(("format", "codec"),