Skip to content

Commit

Permalink
Merge pull request #25 from Trimatix/5-import
Browse files Browse the repository at this point in the history
5 import
  • Loading branch information
Trimatix authored Feb 25, 2024
2 parents 38b471e + ad8d6ef commit 0e5bba6
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 35 deletions.
9 changes: 6 additions & 3 deletions src/AEPi/codec.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
"""
Expand Down
5 changes: 3 additions & 2 deletions src/AEPi/codecs/EtcPakCodec.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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")

5 changes: 3 additions & 2 deletions src/AEPi/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import Enum
from typing import Literal
from AEPi.lib.binaryio import Endianness

class CompressionFormat(Enum):
"""Compression formats.
Expand Down Expand Up @@ -27,5 +28,5 @@ def isCompressed(self):
)

FILE_TYPE_HEADER = b"AEimage\x00"
ENDIANNESS = "<"
CompressionQuality = Literal[1, 2, 3]
ENDIANNESS = Endianness("little", "<")
CompressionQuality = Literal[1, 2, 3]
88 changes: 73 additions & 15 deletions src/AEPi/image/AEI.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions src/AEPi/image/texture.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 66 additions & 5 deletions src/AEPi/lib/binaryio.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Loading

0 comments on commit 0e5bba6

Please sign in to comment.