diff --git a/pytiled_parser/__init__.py b/pytiled_parser/__init__.py index 5ec4574..bcfa056 100644 --- a/pytiled_parser/__init__.py +++ b/pytiled_parser/__init__.py @@ -1,4 +1,4 @@ from . import utilities from . import objects -from .parser import parse_tile_map +from .xml_parser import parse_tile_map diff --git a/pytiled_parser/objects.py b/pytiled_parser/objects.py index 086a9af..a805a7b 100644 --- a/pytiled_parser/objects.py +++ b/pytiled_parser/objects.py @@ -11,30 +11,11 @@ from pathlib import Path import xml.etree.ElementTree as etree -from typing import * # pylint: disable=W0401 - - -class EncodingError(Exception): - """ - Tmx layer encoding is of an unknown type. - """ - - -class TileNotFoundError(Exception): - """ - Tile not found in tileset. - """ - - -class ImageNotFoundError(Exception): - """ - Image not found. - """ +from typing import NamedTuple, Union, Optional, List, Dict class Color(NamedTuple): - """ - Color object. + """Color object. Attributes: :red (int): Red, between 1 and 255. @@ -42,6 +23,7 @@ class Color(NamedTuple): :blue (int): Blue, between 1 and 255. :alpha (int): Alpha, between 1 and 255. """ + red: int green: int blue: int @@ -49,17 +31,29 @@ class Color(NamedTuple): class OrderedPair(NamedTuple): - """ - OrderedPair NamedTuple. + """OrderedPair NamedTuple. Attributes: - :x (Union[int, float]): X coordinate. - :y (Union[int, float]): Y coordinate. + x (Union[int, float]): X coordinate. + y (Union[int, float]): Y coordinate. """ + x: Union[int, float] y: Union[int, float] +class Size(NamedTuple): + """Size NamedTuple. + + Attributes: + width (Union[int, float]): The width of the object. + size (Union[int, float]): The height of the object. + """ + + width: Union[int, float] + height: Union[int, float] + + class Template: """ FIXME TODO @@ -80,13 +74,15 @@ class Chunk: :layer_data (List[List(int)]): The global tile IDs in chunky according to row. """ + location: OrderedPair width: int height: int chunk_data: List[List[int]] -class Image(NamedTuple): +@dataclasses.dataclass +class Image: """ Image object. @@ -101,9 +97,10 @@ class Image(NamedTuple): (optional, used for tile index correction when the image changes). :height (Optional[str]): The image height in pixels (optional). """ + source: str - size: OrderedPair - trans: Optional[Color] + size: Optional[Size] = None + trans: Optional[Color] = None Properties = Dict[str, Union[int, float, Color, Path, str]] @@ -117,6 +114,7 @@ class Grid(NamedTuple): determines how tile overlays for terrain and collision information are rendered. """ + orientation: str width: int height: int @@ -131,6 +129,7 @@ class Terrain(NamedTuple): :tile (int): The local tile-id of the tile that represents the terrain visually. """ + name: str tile: int @@ -147,6 +146,7 @@ class Frame(NamedTuple): :duration (int): How long in milliseconds this frame should be displayed before advancing to the next frame. """ + tile_id: int duration: int @@ -165,6 +165,7 @@ class TileTerrain: :bottom_left (Optional[int]): Bottom left terrain type. :bottom_right (Optional[int]): Bottom right terrain type. """ + top_left: Optional[int] = None top_right: Optional[int] = None bottom_left: Optional[int] = None @@ -172,44 +173,35 @@ class TileTerrain: @dataclasses.dataclass -class _LayerTypeBase: - id: int # pylint: disable=C0103 - name: str - - -@dataclasses.dataclass -class _LayerTypeDefaults: - offset: OrderedPair = OrderedPair(0, 0) - opacity: int = 0xFF - - properties: Optional[Properties] = None - - -@dataclasses.dataclass -class LayerType(_LayerTypeDefaults, _LayerTypeBase): - """ - Class that all layer classes inherit from. - - Not to be directly used. +class Layer: + """Class that all layers inherret from. Args: - :layer_element (etree.Element): Element to be parsed into a - LayerType object. - - Attributes: - :id (int): Unique ID of the layer. Each layer that added to a map - gets a unique id. Even if a layer is deleted, no layer ever gets - the same ID. - :name (Optional[str):] The name of the layer object. - :offset (OrderedPair): Rendering offset of the layer object in - pixels. (default: (0, 0). - :opacity (int): Value between 0 and 255 to determine opacity. NOTE: - this value is converted from a float provided by Tiled, so some - precision is lost. - :properties (Optional[Properties]): Properties object for layer - object. + id: Unique ID of the layer. Each layer that added to a map gets a + unique id. Even if a layer is deleted, no layer ever gets the same + ID. + name: The name of the layer object. + tiled_objects: List of tiled_objects in the layer. + offset: Rendering offset of the layer object in pixels. + opacity: Decimal value between 0 and 1 to determine opacity. 1 is + completely opaque, 0 is completely transparent. + properties: Properties for the layer. + color: The color used to display the objects in this group. + FIXME: editor only? + draworder: Whether the objects are drawn according to the order of the + object elements in the object group element ('manual'), or sorted + by their y-coordinate ('topdown'). Defaults to 'topdown'. See: + https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order + for more info. """ + id: int + name: str + + offset: Optional[OrderedPair] + opacity: Optional[float] + properties: Optional[Properties] + LayerData = Union[List[List[int]], List[Chunk]] """ @@ -221,38 +213,34 @@ Either a 2 dimensional array of integers representing the global tile IDs @dataclasses.dataclass -class _LayerBase: - size: OrderedPair +class TileLayer(Layer): + """Tile map layer containing tiles. + + See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#layer + + Args: + size: The width of the layer in tiles. The same as the map width + unless map is infitite. + data: Either an 2 dimensional array of integers representing the + global tile IDs for the map layer, or a list of chunks for an + infinite map. + """ + + size: Size data: LayerData @dataclasses.dataclass -class Layer(LayerType, _LayerBase): - """ - Map layer object. - - See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#layer - - Attributes: - :size (OrderedPair): The width of the layer in tiles. Always the same - as the map width for not infitite maps. - :data (LayerData): Either an 2 dimensional array of integers - representing the global tile IDs for the map layer, or a list of - chunks for an infinite map. - """ - - -@dataclasses.dataclass -class _ObjectBase: +class _TiledObjectBase: id: int location: OrderedPair @dataclasses.dataclass -class _ObjectDefaults: - size: OrderedPair = OrderedPair(0, 0) +class _TiledObjectDefaults: + size: Size = Size(0, 0) rotation: int = 0 - opacity: int = 0xFF + opacity: float = 1 name: Optional[str] = None type: Optional[str] = None @@ -262,33 +250,33 @@ class _ObjectDefaults: @dataclasses.dataclass -class Object(_ObjectDefaults, _ObjectBase): +class TiledObject(_TiledObjectDefaults, _TiledObjectBase): """ - ObjectGroup Object. + TiledObject object. - See: \ - https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#object + See: + https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#object Args: :id (int): Unique ID of the object. Each object that is placed on a map gets a unique id. Even if an object was deleted, no object gets the same ID. :location (OrderedPair): The location of the object in pixels. - :size (OrderedPair): The width of the object in pixels + :size (Size): The width of the object in pixels (default: (0, 0)). :rotation (int): The rotation of the object in degrees clockwise (default: 0). :opacity (int): The opacity of the object. (default: 255) :name (Optional[str]): The name of the object. :type (Optional[str]): The type of the object. - :properties (Properties): The properties of the Object. + :properties (Properties): The properties of the TiledObject. :template Optional[Template]: A reference to a Template object FIXME """ @dataclasses.dataclass -class RectangleObject(Object): +class RectangleObject(TiledObject): """ Rectangle shape defined by a point, width, and height. @@ -299,7 +287,7 @@ class RectangleObject(Object): @dataclasses.dataclass -class ElipseObject(Object): +class ElipseObject(TiledObject): """ Elipse shape defined by a point, width, and height. @@ -308,7 +296,7 @@ class ElipseObject(Object): @dataclasses.dataclass -class PointObject(Object): +class PointObject(TiledObject): """ Point defined by a point (x,y). @@ -317,12 +305,12 @@ class PointObject(Object): @dataclasses.dataclass -class _TileObjectBase(_ObjectBase): +class _TileImageObjectBase(_TiledObjectBase): gid: int @dataclasses.dataclass -class TileObject(Object, _TileObjectBase): +class TileImageObject(TiledObject, _TileImageObjectBase): """ Polygon shape defined by a set of connections between points. @@ -334,12 +322,12 @@ class TileObject(Object, _TileObjectBase): @dataclasses.dataclass -class _PointsObjectBase(_ObjectBase): +class _PointsObjectBase(_TiledObjectBase): points: List[OrderedPair] @dataclasses.dataclass -class PolygonObject(Object, _PointsObjectBase): +class PolygonObject(TiledObject, _PointsObjectBase): """ Polygon shape defined by a set of connections between points. @@ -351,12 +339,12 @@ class PolygonObject(Object, _PointsObjectBase): @dataclasses.dataclass -class PolylineObject(Object, _PointsObjectBase): +class PolylineObject(TiledObject, _PointsObjectBase): """ Polyline defined by a set of connections between points. - See: \ -https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#polyline + See: + https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#polyline Attributes: :points (List[Tuple[int, int]]): List of coordinates relative to \ @@ -365,27 +353,27 @@ https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#polyline @dataclasses.dataclass -class _TextObjectBase(_ObjectBase): +class _TextObjectBase(_TiledObjectBase): text: str @dataclasses.dataclass -class _TextObjectDefaults(_ObjectDefaults): - font_family: str = 'sans-serif' +class _TextObjectDefaults(_TiledObjectDefaults): + font_family: str = "sans-serif" font_size: int = 16 wrap: bool = False - color: Color = Color(0xFF, 0, 0, 0) + color: str = "#000000" bold: bool = False italic: bool = False underline: bool = False strike_out: bool = False kerning: bool = False - horizontal_align: str = 'left' - vertical_align: str = 'top' + horizontal_align: str = "left" + vertical_align: str = "top" @dataclasses.dataclass -class TextObject(Object, _TextObjectDefaults, _TextObjectBase): +class TextObject(TiledObject, _TextObjectDefaults, _TextObjectBase): """ Text object with associated settings. @@ -410,45 +398,36 @@ class TextObject(Object, _TextObjectDefaults, _TextObjectBase): @dataclasses.dataclass -class _ObjectGroupBase(_LayerTypeBase): - objects: List[Object] - - -@dataclasses.dataclass -class _ObjectGroupDefaults(_LayerTypeDefaults): - color: Optional[Color] = None - draw_order: Optional[str] = 'topdown' - - -@dataclasses.dataclass -class ObjectGroup(LayerType, _ObjectGroupDefaults, _ObjectGroupBase): +class ObjectLayer(Layer): """ - Object Group Object. + TiledObject Group Object. The object group is in fact a map layer, and is hence called \ “object layer” in Tiled. - See: \ -https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#objectgroup + See: + https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#objectgroup - Attributes: - :color (Optional[Color]): The color used to display the objects - in this group. FIXME: editor only? - :draworder (str): Whether the objects are drawn according to the - order of the object elements in the object group element - ('manual'), or sorted by their y-coordinate ('topdown'). Defaults - to 'topdown'. See: + Args: + tiled_objects: List of tiled_objects in the layer. + offset: Rendering offset of the layer object in pixels. + color: The color used to display the objects in this group. + FIXME: editor only? + draworder: Whether the objects are drawn according to the order of the + object elements in the object group element ('manual'), or sorted + by their y-coordinate ('topdown'). Defaults to 'topdown'. See: https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order for more info. - :objects (Dict[int, Object]): Dict Object objects by Object.id. """ + tiled_objects: List[TiledObject] -class _LayerGroupBase(_LayerTypeBase): - layers: Optional[List[LayerType]] + color: Optional[str] = None + draw_order: Optional[str] = "topdown" -class LayerGroup(LayerType): +@dataclasses.dataclass +class LayerGroup(Layer): """ Layer Group. @@ -463,6 +442,14 @@ class LayerGroup(LayerType): """ + layers: Optional[List[Union["LayerGroup", Layer, ObjectLayer]]] + + +@dataclasses.dataclass +class Hitbox: + """Group of hitboxes for + """ + @dataclasses.dataclass class Tile: @@ -477,12 +464,13 @@ class Tile: :animation (List[Frame]): Each tile can have exactly one animation associated with it. """ + id: int type: Optional[str] terrain: Optional[TileTerrain] animation: Optional[List[Frame]] image: Optional[Image] - hit_box: Optional[List[Object]] + hitboxes: Optional[List[TiledObject]] @dataclasses.dataclass @@ -492,7 +480,7 @@ class TileSet: Args: :name (str): The name of this tileset. - :max_tile_size (OrderedPair): The maximum size of a tile in this + :max_tile_size (Size): The maximum size of a tile in this tile set in pixels. :spacing (int): The spacing in pixels between the tiles in this tileset (applies to the tileset image). @@ -515,8 +503,9 @@ class TileSet: file. :tiles (Optional[Dict[int, Tile]]): Dict of Tile objects by Tile.id. """ + name: str - max_tile_size: OrderedPair + max_tile_size: Size spacing: Optional[int] margin: Optional[int] tile_count: Optional[int] @@ -529,6 +518,9 @@ class TileSet: tiles: Optional[Dict[int, Tile]] +TileSetDict = Dict[int, TileSet] + + @dataclasses.dataclass class TileMap: """ @@ -548,8 +540,8 @@ class TileMap: rendered. Valid values are right-down, right-up, left-down and left-up. In all cases, the map is drawn row-by-row. (only supported for orthogonal maps at the moment) - :map_size (OrderedPair): The map width in tiles. - :tile_size (OrderedPair): The width of a tile. + :map_size (Size): The map width in tiles. + :tile_size (Size): The width of a tile. :infinite (bool): If the map is infinite or not. :hexsidelength (int): Only for hexagonal maps. Determines the width or height (depending on the staggered axis) of the tile’s edge, in @@ -563,34 +555,35 @@ class TileMap: :nextlayerid (int): Stores the next available ID for new layers. :nextobjectid (int): Stores the next available ID for new objects. :tile_sets (dict[str, TileSet]): Dict of tile sets used - in this map. Key is the source for external tile sets or the name - for embedded ones. The value is a TileSet object. + in this map. Key is the first GID for the tile set. The value + is a TileSet object. :layers List[LayerType]: List of layer objects by draw order. """ + parent_dir: Path version: str tiled_version: str orientation: str render_order: str - map_size: OrderedPair - tile_size: OrderedPair + map_size: Size + tile_size: Size infinite: bool next_layer_id: int next_object_id: int - tile_sets: Dict[int, TileSet] - layers: List[LayerType] + tile_sets: TileSetDict + layers: List[Layer] hex_side_length: Optional[int] = None stagger_axis: Optional[int] = None stagger_index: Optional[int] = None - background_color: Optional[Color] = None + background_color: Optional[str] = None properties: Optional[Properties] = None -''' +""" [22:16] <__m4ch1n3__> i would "[i for i in int_list if i < littler_then_value]" [22:16] <__m4ch1n3__> it returns a list of integers below "littler_then_value" [22:17] <__m4ch1n3__> !py3 [i for i in [1,2,3,4,1,2,3,4] if i < 3] @@ -606,6 +599,4 @@ class TileMap: [22:23] __m4ch1n3__: 100 [22:23] == markb1 [~mbiggers@45.36.35.206] has quit [Ping timeout: 245 seconds] [22:23] <__m4ch1n3__> !py3 max(i for i in [1, 10, 100] if i < 242) -''' - -#buffer +""" diff --git a/pytiled_parser/parser.py b/pytiled_parser/parser.py deleted file mode 100644 index 462489f..0000000 --- a/pytiled_parser/parser.py +++ /dev/null @@ -1,581 +0,0 @@ -import functools -import re -import base64 -import zlib - -from pathlib import Path - -from typing import * - -import pytiled_parser.objects as objects -import pytiled_parser.utilities as utilities - -import xml.etree.ElementTree as etree - -def _decode_base64_data(data_text, compression, layer_width): - tile_grid: List[List[int]] = [[]] - - unencoded_data = base64.b64decode(data_text) - if compression == "zlib": - unzipped_data = zlib.decompress(unencoded_data) - elif compression == "gzip": - unzipped_data = gzip.decompress(unencoded_data) - elif compression is None: - unzipped_data = unencoded_data - else: - raise ValueError(f"Unsupported compression type '{compression}'.") - - # Turn bytes into 4-byte integers - byte_count = 0 - int_count = 0 - int_value = 0 - row_count = 0 - for byte in unzipped_data: - int_value += byte << (byte_count * 8) - byte_count += 1 - if byte_count % 4 == 0: - byte_count = 0 - int_count += 1 - tile_grid[row_count].append(int_value) - int_value = 0 - if int_count % layer_width == 0: - row_count += 1 - tile_grid.append([]) - - tile_grid.pop() - return tile_grid - - -def _decode_csv_layer(data_text): - """ - Decodes csv encoded layer data. - - Credit: - """ - tile_grid = [] - lines = data_text.split("\n") - # remove erronious empty lists due to a newline being on both ends of text - lines = lines[1:] - lines = lines[:-1] - for line in lines: - line_list = line.split(",") - while '' in line_list: - line_list.remove('') - line_list_int = [int(item) for item in line_list] - tile_grid.append(line_list_int) - - return tile_grid - - -def _decode_data(element: etree.Element, layer_width: int, encoding: str, - compression: Optional[str]) -> List[List[int]]: - """ - Decodes data or chunk data. - - Args: - :element (Element): Element to have text decoded. - :layer_width (int): Number of tiles per column in this layer. Used - for determining when to cut off a row when decoding base64 - encoding layers. - :encoding (str): Encoding format of the layer data. - :compression (str): Compression format of the layer data. - """ - # etree.Element.text comes with an appended and a prepended '\n' - supported_encodings = ['base64', 'csv'] - if encoding not in supported_encodings: - raise ValueError('{encoding} is not a valid encoding') - - supported_compression = [None, 'gzip', 'zlib'] - if compression is not None: - if encoding != 'base64': - raise ValueError('{encoding} does not support compression') - if compression not in supported_compression: - raise ValueError('{compression} is not a valid compression type') - - try: - data_text = element.text # type: ignore - except AttributeError: - raise AttributeError('{element} lacks layer data.') - - if encoding == 'csv': - return _decode_csv_layer(data_text) - - return _decode_base64_data(data_text, compression, layer_width) - - -def _parse_data(element: etree.Element, - layer_width: int) -> objects.LayerData: - """ - Parses layer data. - - Will parse CSV, base64, gzip-base64, or zlip-base64 encoded data. - - Args: - :element (Element): Data element to parse. - :width (int): Layer width. Used for base64 decoding. - - Returns: - :LayerData: Data object containing layer data or chunks of data. - """ - encoding = element.attrib['encoding'] - compression = None - try: - compression = element.attrib['compression'] - except KeyError: - pass - - chunk_elements = element.findall('./chunk') - if chunk_elements: - chunks: List[objects.Chunk] = [] - for chunk_element in chunk_elements: - x = int(chunk_element.attrib['x']) - y = int(chunk_element.attrib['y']) - location = objects.OrderedPair(x, y) - width = int(chunk_element.attrib['width']) - height = int(chunk_element.attrib['height']) - layer_data = _decode_data(chunk_element, layer_width, encoding, - compression) - chunks.append(objects.Chunk(location, width, height, layer_data)) - return chunks - - return _decode_data(element, layer_width, encoding, compression) - - -def _parse_layer(element: etree.Element, - layer_type: objects.LayerType) -> objects.Layer: - """ - Parse layer element given. - """ - width = int(element.attrib['width']) - height = int(element.attrib['height']) - size = objects.OrderedPair(width, height) - data_element = element.find('./data') - if data_element is not None: - data: objects.LayerData = _parse_data(data_element, width) - else: - raise ValueError('{element} has no child data element.') - - return objects.Layer(size, data, **layer_type.__dict__) - - -def _parse_layer_type(layer_element: etree.Element) -> objects.LayerType: - """ - Parse layer type element given. - """ - id = int(layer_element.attrib['id']) - - name = layer_element.attrib['name'] - - layer_type_object = objects.LayerType(id, name) - - try: - offset_x = float(layer_element.attrib['offsetx']) - except KeyError: - offset_x = 0 - - try: - offset_y = float(layer_element.attrib['offsety']) - except KeyError: - offset_y = 0 - offset = objects.OrderedPair(offset_x, offset_y) - - try: - layer_type_object.opacity = round( - float(layer_element.attrib['opacity']) * 255) - except KeyError: - pass - - properties_element = layer_element.find('./properties') - if properties_element is not None: - layer_type_object.properties = _parse_properties_element( - properties_element) - - if layer_element.tag == 'layer': - return _parse_layer(layer_element, layer_type_object) - elif layer_element.tag == 'objectgroup': - return _parse_object_group(layer_element, layer_type_object) - # else: - # return _parse_layer_group(layer_element, layer_type_object) - - -def _parse_object_group(element: etree.Element, - layer_type: objects.LayerType) -> objects.ObjectGroup: - """ - Parse object group element given. - """ - object_elements = element.findall('./object') - tile_objects: List[objects.Object] = [] - - for object_element in object_elements: - id = int(object_element.attrib['id']) - location_x = float(object_element.attrib['x']) - location_y = float(object_element.attrib['y']) - location = objects.OrderedPair(location_x, location_y) - - object = objects.Object(id, location) - - try: - width = float(object_element.attrib['width']) - except KeyError: - width = 0 - - try: - height = float(object_element.attrib['height']) - except KeyError: - height = 0 - - object.size = objects.OrderedPair(width, height) - - try: - object.opacity = round( - float(object_element.attrib['opacity']) * 255) - except KeyError: - pass - - try: - object.rotation = int(object_element.attrib['rotation']) - except KeyError: - pass - - try: - object.name = object_element.attrib['name'] - except KeyError: - pass - - properties_element = object_element.find('./properties') - if properties_element is not None: - object.properties = _parse_properties_element(properties_element) - - tile_objects.append(object) - - object_group = objects.ObjectGroup(tile_objects, **layer_type.__dict__) - try: - color = utilities.parse_color(element.attrib['color']) - except KeyError: - pass - - try: - draw_order = element.attrib['draworder'] - except KeyError: - pass - - return object_group - - -@functools.lru_cache() -def _parse_external_tile_set(parent_dir: Path, tile_set_element: etree.Element - ) -> objects.TileSet: - """ - Parses an external tile set. - - Caches the results to speed up subsequent instances. - """ - source = Path(tile_set_element.attrib['source']) - tile_set_tree = etree.parse(str(parent_dir / Path(source))).getroot() - - return _parse_tile_set(tile_set_tree) - - -def _parse_tiles(tile_element_list: List[etree.Element] - ) -> Dict[int, objects.Tile]: - tiles: Dict[int, objects.Tile] = {} - for tile_element in tile_element_list: - # id is not optional - id = int(tile_element.attrib['id']) - - # optional attributes - type = None - try: - type = tile_element.attrib['type'] - except KeyError: - pass - - tile_terrain = None - try: - tile_terrain_attrib = tile_element.attrib['terrain'] - except KeyError: - pass - else: - # an attempt to explain how terrains are handled is below. - # 'terrain' attribute is a comma seperated list of 4 values, - # each is either an integer or blank - - # convert to list of values - terrain_list_attrib = re.split(',', tile_terrain_attrib) - # terrain_list is list of indexes of Tileset.terrain_types - terrain_list: List[Optional[int]] = [] - # each index in terrain_list_attrib reffers to a corner - for corner in terrain_list_attrib: - if corner == '': - terrain_list.append(None) - else: - terrain_list.append(int(corner)) - tile_terrain = objects.TileTerrain(*terrain_list) - - # tile element optional sub-elements - animation: Optional[List[objects.Frame]] = None - tile_animation_element = tile_element.find('./animation') - if tile_animation_element: - animation = [] - frames = tile_animation_element.findall('./frame') - for frame in frames: - # tileid reffers to the Tile.id of the animation frame - tile_id = int(frame.attrib['tileid']) - # duration is in MS. Should perhaps be converted to seconds. - # FIXME: make decision - duration = int(frame.attrib['duration']) - animation.append(objects.Frame(tile_id, duration)) - - # if this is None, then the Tile is part of a spritesheet - tile_image = None - tile_image_element = tile_element.find('./image') - if tile_image_element is not None: - tile_image = _parse_image_element(tile_image_element) - - object_group = None - tile_object_group_element = tile_element.find('./objectgroup') - if tile_object_group_element: - ### FIXME: why did they do this :( - pass - - tiles[id] = objects.Tile(id, type, tile_terrain, animation, - tile_image, object_group) - - return tiles - - -def _parse_image_element(image_element: etree.Element) -> objects.Image: - """ - Parse image element given. - - Returns: - :Color: Color in Arcade's preffered format. - """ - source = image_element.attrib['source'] - - trans = None - try: - trans = utilities.parse_color(image_element.attrib['trans']) - except KeyError: - pass - - width = int(image_element.attrib['width']) - height = int(image_element.attrib['height']) - size = objects.OrderedPair(width, height) - - return objects.Image(source, size, trans) - - -def _parse_properties_element(properties_element: etree.Element - ) -> objects.Properties: - """ - Adds Tiled property to Properties dict. - - Args: - :name (str): Name of property. - :property_type (str): Type of property. Can be string, int, float, - bool, color or file. Defaults to string. - :value (str): The value of the property. - - Returns: - :Properties: Properties Dict object. - """ - properties: objects.Properties = {} - for property_element in properties_element.findall('./property'): - name = property_element.attrib['name'] - try: - property_type = property_element.attrib['type'] - except KeyError: - # strings do not have an attribute in property elements - property_type = 'string' - value = property_element.attrib['value'] - - property_types = ['string', 'int', 'float', 'bool', 'color', 'file'] - assert property_type in property_types, ( - f"Invalid type for property {name}") - - if property_type == 'int': - properties[name] = int(value) - elif property_type == 'float': - properties[name] = float(value) - elif property_type == 'color': - properties[name] = utilities.parse_color(value) - elif property_type == 'file': - properties[name] = Path(value) - elif property_type == 'bool': - if value == 'true': - properties[name] = True - else: - properties[name] = False - else: - properties[name] = value - - return properties - - -def _parse_tile_set(tile_set_element: etree.Element) -> objects.TileSet: - """ - Parses a tile set that is embedded into a TMX. - """ - # get all basic attributes - name = tile_set_element.attrib['name'] - max_tile_width = int(tile_set_element.attrib['tilewidth']) - max_tile_height = int(tile_set_element.attrib['tileheight']) - max_tile_size = objects.OrderedPair(max_tile_width, max_tile_height) - - spacing = None - try: - spacing = int(tile_set_element.attrib['spacing']) - except KeyError: - pass - - margin = None - try: - margin = int(tile_set_element.attrib['margin']) - except KeyError: - pass - - tile_count = None - try: - tile_count = int(tile_set_element.attrib['tilecount']) - except KeyError: - pass - - columns = None - try: - columns = int(tile_set_element.attrib['columns']) - except KeyError: - pass - - tile_offset = None - tileoffset_element = tile_set_element.find('./tileoffset') - if tileoffset_element is not None: - tile_offset_x = int(tileoffset_element.attrib['x']) - tile_offset_y = int(tileoffset_element.attrib['y']) - tile_offset = objects.OrderedPair(tile_offset_x, tile_offset_y) - - grid = None - grid_element = tile_set_element.find('./grid') - if grid_element is not None: - grid_orientation = grid_element.attrib['orientation'] - grid_width = int(grid_element.attrib['width']) - grid_height = int(grid_element.attrib['height']) - grid = objects.Grid(grid_orientation, grid_width, grid_height) - - properties = None - properties_element = tile_set_element.find('./properties') - if properties_element is not None: - properties = _parse_properties_element(properties_element) - - terrain_types: Optional[List[objects.Terrain]] = None - terrain_types_element = tile_set_element.find('./terraintypes') - if terrain_types_element is not None: - terrain_types = [] - for terrain in terrain_types_element.findall('./terrain'): - name = terrain.attrib['name'] - terrain_tile = int(terrain.attrib['tile']) - terrain_types.append(objects.Terrain(name, terrain_tile)) - - image = None - image_element = tile_set_element.find('./image') - if image_element is not None: - image = _parse_image_element(image_element) - - tile_element_list = tile_set_element.findall('./tile') - tiles = _parse_tiles(tile_element_list) - - return objects.TileSet(name, max_tile_size, spacing, margin, tile_count, - columns, tile_offset, grid, properties, image, - terrain_types, tiles) - - -def parse_tile_map(tmx_file: Union[str, Path]): - # setting up XML parsing - map_tree = etree.parse(str(tmx_file)) - map_element = map_tree.getroot() - - # positional arguments for TileMap - parent_dir = Path(tmx_file).parent - - version = map_element.attrib['version'] - tiled_version = map_element.attrib['tiledversion'] - orientation = map_element.attrib['orientation'] - render_order = map_element.attrib['renderorder'] - map_width = int(map_element.attrib['width']) - map_height = int(map_element.attrib['height']) - map_size = objects.OrderedPair(map_width, map_height) - tile_width = int(map_element.attrib['tilewidth']) - tile_height = int(map_element.attrib['tileheight']) - tile_size = objects.OrderedPair(tile_width, tile_height) - - infinite_attribute = map_element.attrib['infinite'] - infinite = True if infinite_attribute == 'true' else False - - next_layer_id = int(map_element.attrib['nextlayerid']) - next_object_id = int(map_element.attrib['nextobjectid']) - - # parse all tilesets - tile_sets: Dict[int, objects.TileSet] = {} - tile_set_element_list = map_element.findall('./tileset') - for tile_set_element in tile_set_element_list: - # tiled docs are ambiguous about the 'firstgid' attribute - # current understanding is for the purposes of mapping the layer - # data to the tile set data, add the 'firstgid' value to each - # tile 'id'; this means that the 'firstgid' is specific to each, - # tile set as they pertain to the map, not tile set specific as - # the tiled docs can make it seem - # 'firstgid' is saved beside each TileMap - firstgid = int(tile_set_element.attrib['firstgid']) - try: - # check if is an external TSX - source = tile_set_element.attrib['source'] - except KeyError: - # the tile set in embedded - name = tile_set_element.attrib['name'] - tile_sets[firstgid] = _parse_tile_set(tile_set_element) - else: - # tile set is external - tile_sets[firstgid] = _parse_external_tile_set( - parent_dir, tile_set_element) - - # parse all layers - layers: List[objects.LayerType] = [] - layer_tags = ['layer', 'objectgroup', 'group'] - for element in map_element.findall('./'): - if element.tag not in layer_tags: - # only layer_tags are layer elements - continue - layers.append(_parse_layer_type(element)) - - tile_map = objects.TileMap(parent_dir, version, tiled_version, - orientation, render_order, map_size, tile_size, - infinite, next_layer_id, next_object_id, - tile_sets, layers) - - try: - tile_map.hex_side_length = int(map_element.attrib['hexsidelength']) - except KeyError: - pass - - try: - tile_map.stagger_axis = int(map_element.attrib['staggeraxis']) - except KeyError: - pass - - try: - tile_map.stagger_index = int(map_element.attrib['staggerindex']) - except KeyError: - pass - - try: - backgroundcolor = map_element.attrib['backgroundcolor'] - except KeyError: - pass - else: - tile_map.background_color = utilities.parse_color(backgroundcolor) - - properties_element = map_tree.find('./properties') - if properties_element is not None: - tile_map.properties = _parse_properties_element(properties_element) - - return tile_map diff --git a/pytiled_parser/utilities.py b/pytiled_parser/utilities.py index a7bf9fc..32d308e 100644 --- a/pytiled_parser/utilities.py +++ b/pytiled_parser/utilities.py @@ -9,7 +9,7 @@ def parse_color(color: str) -> objects.Color: :Color: Color object in the format that Arcade understands. """ # strip initial '#' character - if not len(color) % 2 == 0: # pylint: disable=C2001 + if not len(color) % 2 == 0: color = color[1:] if len(color) == 6: @@ -25,3 +25,21 @@ def parse_color(color: str) -> objects.Color: blue = int(color[6:8], 16) return objects.Color(red, green, blue, alpha) + + +def get_tile_by_gid(tile_sets: objects.TileSetDict, gid: int) -> objects.Tile: + """Gets Tile from a global tile ID. + + Args: + tile_sets (objects.TileSetDict): TileSetDict from TileMap. + gid (int): Global tile ID of the tile to be returned. + + Returns: + objects.Tile: The Tile object reffered to by the global tile ID. + """ + for tileset_key, tileset in tile_sets.items(): + for tile_key, tile in tileset.tiles.items(): + tile_gid = tile.id + tileset_key + if tile_gid == gid: + return tile + return None diff --git a/pytiled_parser/xml_parser.py b/pytiled_parser/xml_parser.py new file mode 100644 index 0000000..8ca4649 --- /dev/null +++ b/pytiled_parser/xml_parser.py @@ -0,0 +1,742 @@ +import functools +import base64 +import gzip +import re +import zlib + +from pathlib import Path + +from typing import Callable, Dict, List, Optional, Tuple, Union +import xml.etree.ElementTree as etree + +import pytiled_parser.objects as objects +import pytiled_parser.utilities as utilities + + +def _decode_base64_data( + data_text: str, layer_width: int, compression: Optional[str] = None +) -> List[List[int]]: + tile_grid: List[List[int]] = [[]] + + unencoded_data = base64.b64decode(data_text) + if compression == "zlib": + unzipped_data = zlib.decompress(unencoded_data) + elif compression == "gzip": + unzipped_data = gzip.decompress(unencoded_data) + elif compression is None: + unzipped_data = unencoded_data + else: + raise ValueError(f"Unsupported compression type '{compression}'.") + + # Turn bytes into 4-byte integers + byte_count = 0 + int_count = 0 + int_value = 0 + row_count = 0 + for byte in unzipped_data: + int_value += byte << (byte_count * 8) + byte_count += 1 + if byte_count % 4 == 0: + byte_count = 0 + int_count += 1 + tile_grid[row_count].append(int_value) + int_value = 0 + if int_count % layer_width == 0: + row_count += 1 + tile_grid.append([]) + + tile_grid.pop() + return tile_grid + + +def _decode_csv_data(data_text: str) -> List[List[int]]: + """Decodes csv encoded layer data. + + Credit: + """ + tile_grid = [] + lines: List[str] = data_text.split("\n") + # remove erronious empty lists due to a newline being on both ends of text + lines = lines[1:-1] + for line in lines: + line_list = line.split(",") + # FIXME: what is this for? + while "" in line_list: + line_list.remove("") + line_list_int = [int(item) for item in line_list] + tile_grid.append(line_list_int) + + return tile_grid + + +def _decode_data( + element: etree.Element, + layer_width: int, + encoding: str, + compression: Optional[str], +) -> List[List[int]]: + """Decodes data or chunk data. + + Args: + :element (Element): Element to have text decoded. + :layer_width (int): Number of tiles per column in this layer. Used + for determining when to cut off a row when decoding base64 + encoding layers. + :encoding (str): Encoding format of the layer data. + :compression (str): Compression format of the layer data. + """ + # etree.Element.text comes with an appended and a prepended '\n' + supported_encodings = ["base64", "csv"] + if encoding not in supported_encodings: + raise ValueError("{encoding} is not a valid encoding") + + supported_compression = [None, "gzip", "zlib"] + if compression is not None: + if encoding != "base64": + raise ValueError("{encoding} does not support compression") + if compression not in supported_compression: + raise ValueError("{compression} is not a valid compression type") + + try: + data_text: str = element.text # type: ignore + except AttributeError: + raise AttributeError(f"{element} lacks layer data.") + + if encoding == "csv": + return _decode_csv_data(data_text) + + return _decode_base64_data(data_text, compression, layer_width) + + +def _parse_data( + element: etree.Element, layer_width: int +) -> objects.LayerData: + """Parses layer data. + + Will parse CSV, base64, gzip-base64, or zlip-base64 encoded data. + + Args: + :element (Element): Data element to parse. + :width (int): Layer width. Used for base64 decoding. + + Returns: + :LayerData: Data object containing layer data or chunks of data. + """ + encoding = element.attrib["encoding"] + compression = None + try: + compression = element.attrib["compression"] + except KeyError: + pass + + chunk_elements = element.findall("./chunk") + if chunk_elements: + chunks: List[objects.Chunk] = [] + for chunk_element in chunk_elements: + x = int(chunk_element.attrib["x"]) + y = int(chunk_element.attrib["y"]) + location = objects.OrderedPair(x, y) + width = int(chunk_element.attrib["width"]) + height = int(chunk_element.attrib["height"]) + layer_data = _decode_data( + chunk_element, layer_width, encoding, compression + ) + chunks.append(objects.Chunk(location, width, height, layer_data)) + return chunks + + return _decode_data(element, layer_width, encoding, compression) + + +def _parse_layer( + layer_element: etree.Element +) -> Tuple[ + int, + str, + Optional[objects.OrderedPair], + Optional[float], + Optional[objects.Properties], +]: + """Parses all of the attributes for a Layer object. + + Args: + layer_element: The layer element to be parsed. + + Returns: + FIXME + """ + id = int(layer_element.attrib["id"]) + + name = layer_element.attrib["name"] + + offset: Optional[objects.OrderedPair] + offset_x_attrib = layer_element.attrib.get("offsetx") + offset_y_attrib = layer_element.attrib.get("offsety") + # If any offset is present, we need to return an OrderedPair + # Unknown if one of the offsets could be absent. + if any([offset_x_attrib, offset_y_attrib]): + if offset_x_attrib: + offset_x = float(offset_x_attrib) + else: + offset_x = 0.0 + if offset_y_attrib: + offset_y = float(offset_y_attrib) + else: + offset_y = 0.0 + + offset = objects.OrderedPair(offset_x, offset_y) + else: + offset = None + + opacity: Optional[float] + opacity_attrib = layer_element.attrib.get("opacity") + if opacity_attrib: + opacity = float(opacity_attrib) + else: + opacity = None + + properties: Optional[objects.Properties] + properties_element = layer_element.find("./properties") + if properties_element is not None: + properties = _parse_properties_element(properties_element) + else: + properties = None + + return id, name, offset, opacity, properties + + +def _parse_tile_layer(element: etree.Element,) -> objects.TileLayer: + """Parses tile layer element. + + Args: + element: The layer element to be parsed. + + Returns: + TileLayer: The tile layer object. + """ + id, name, offset, opacity, properties = _parse_layer(element) + + width = int(element.attrib["width"]) + height = int(element.attrib["height"]) + size = objects.Size(width, height) + + data_element = element.find("./data") + if data_element is not None: + data: objects.LayerData = _parse_data(data_element, width) + else: + raise ValueError(f"{element} has no child data element.") + + return objects.TileLayer( + id, name, offset, opacity, properties, size, data + ) + + +def _parse_objects( + object_elements: List[etree.Element] +) -> List[objects.TiledObject]: + """Parses objects found in the 'objectgroup' element. + + Args: + object_elements: List of object elements to be parsed. + + Returns: + list: List of parsed tiled objects. + """ + tiled_objects: List[objects.TiledObject] = [] + + for object_element in object_elements: + id = int(object_element.attrib["id"]) + location_x = float(object_element.attrib["x"]) + location_y = float(object_element.attrib["y"]) + location = objects.OrderedPair(location_x, location_y) + + tiled_object = objects.TiledObject(id, location) + + try: + width = float(object_element.attrib["width"]) + except KeyError: + width = 0 + + try: + height = float(object_element.attrib["height"]) + except KeyError: + height = 0 + + tiled_object.size = objects.Size(width, height) + + try: + tiled_object.opacity = float(object_element.attrib["opacity"]) + except KeyError: + pass + + try: + tiled_object.rotation = int(object_element.attrib["rotation"]) + except KeyError: + pass + + try: + tiled_object.name = object_element.attrib["name"] + except KeyError: + pass + + try: + tiled_object.type = object_element.attrib["type"] + except KeyError: + pass + + properties_element = object_element.find("./properties") + if properties_element is not None: + tiled_object.properties = _parse_properties_element( + properties_element + ) + + tiled_objects.append(tiled_object) + + return tiled_objects + + +def _parse_object_layer(element: etree.Element,) -> objects.ObjectLayer: + """Parse the objectgroup element given. + + Args: + layer_type (objects.LayerType): + id: The id of the layer. + name: The name of the layer. + offset: The offset of the layer. + opacity: The opacity of the layer. + properties: The Properties object of the layer. + + Returns: + ObjectLayer: The object layer object. + """ + id, name, offset, opacity, properties = _parse_layer(element) + + tiled_objects = _parse_objects(element.findall("./object")) + + try: + color = element.attrib["color"] + except KeyError: + pass + + try: + draw_order = element.attrib["draworder"] + except KeyError: + pass + + return objects.ObjectLayer( + id, + name, + offset, + opacity, + properties, + tiled_objects, + color, + draw_order, + ) + + +def _parse_layer_group(element: etree.Element,) -> objects.LayerGroup: + """Parse the objectgroup element given. + + Args: + layer_type (objects.LayerType): + id: The id of the layer. + name: The name of the layer. + offset: The offset of the layer. + opacity: The opacity of the layer. + properties: The Properties object of the layer. + + Returns: + LayerGroup: The layer group object. + """ + id, name, offset, opacity, properties = _parse_layer(element) + + layers = _get_layers(element) + + return objects.LayerGroup(id, name, offset, opacity, properties, layers) + + +def _get_layer_parser( + layer_tag: str +) -> Optional[Callable[[etree.Element], objects.Layer]]: + """Gets a the parser for the layer type specified. + + Layer tags are 'layer' for a tile layer, 'objectgroup' for an object + layer, and 'group' for a layer group. If anything else is passed, + returns None. + + Args: + layer_tag: Specifies the layer type to be parsed based on the element + tag. + + Returns: + Callable: the function to be used to parse the layer. + None: The element is not a map layer. + """ + if layer_tag == "layer": + return _parse_tile_layer + elif layer_tag == "objectgroup": + return _parse_object_layer + elif layer_tag == "group": + return _parse_layer_group + else: + return None + + +def _get_layers(map_element: etree.Element) -> List[objects.Layer]: + """Parse layer type element given. + + Retains draw order based on the returned lists index FIXME: confirm + + Args: + map_element: The element containing the layer. + + Returns: + List[Layer]: A list of the layers, ordered by draw order. + FIXME: confirm + """ + layers: List[objects.Layer] = [] + for element in map_element.findall("./"): + layer_parser = _get_layer_parser(element.tag) + if layer_parser: + layers.append(layer_parser(element)) + + return layers + + +@functools.lru_cache() +def _parse_external_tile_set( + parent_dir: Path, tile_set_element: etree.Element +) -> objects.TileSet: + """Parses an external tile set. + + Caches the results to speed up subsequent maps with identical tilesets. + """ + source = Path(tile_set_element.attrib["source"]) + tile_set_tree = etree.parse(str(parent_dir / Path(source))).getroot() + + return _parse_tile_set(tile_set_tree) + + +def _parse_hitboxes(element: etree.Element) -> List[objects.TiledObject]: + """Parses all hitboxes for a given tile.""" + return _parse_objects(element.findall("./object")) + + +def _parse_tiles( + tile_element_list: List[etree.Element] +) -> Dict[int, objects.Tile]: + tiles: Dict[int, objects.Tile] = {} + for tile_element in tile_element_list: + # id is not optional + id = int(tile_element.attrib["id"]) + + # optional attributes + tile_type = None + try: + tile_type = tile_element.attrib["type"] + except KeyError: + pass + + tile_terrain = None + try: + tile_terrain_attrib = tile_element.attrib["terrain"] + except KeyError: + pass + else: + # below is an attempt to explain how terrains are handled. + #'terrain' attribute is a comma seperated list of 4 values, + # each is either an integer or blank + + # convert to list of values + terrain_list_attrib = re.split(",", tile_terrain_attrib) + # terrain_list is list of indexes of Tileset.terrain_types + terrain_list: List[Optional[int]] = [] + # each index in terrain_list_attrib reffers to a corner + for corner in terrain_list_attrib: + if corner == "": + terrain_list.append(None) + else: + terrain_list.append(int(corner)) + tile_terrain = objects.TileTerrain(*terrain_list) + + # tile element optional sub-elements + animation: Optional[List[objects.Frame]] = None + tile_animation_element = tile_element.find("./animation") + if tile_animation_element: + animation = [] + frames = tile_animation_element.findall("./frame") + for frame in frames: + # tileid reffers to the Tile.id of the animation frame + tile_id = int(frame.attrib["tileid"]) + # duration is in MS. Should perhaps be converted to seconds. + # FIXME: make decision + duration = int(frame.attrib["duration"]) + animation.append(objects.Frame(tile_id, duration)) + + # if this is None, then the Tile is part of a spritesheet + tile_image = None + tile_image_element = tile_element.find("./image") + if tile_image_element is not None: + tile_image = _parse_image_element(tile_image_element) + + hitboxes = None + tile_hitboxes_element = tile_element.find("./objectgroup") + if tile_hitboxes_element is not None: + hitboxes = _parse_hitboxes(tile_hitboxes_element) + + tiles[id] = objects.Tile( + id, tile_type, tile_terrain, animation, tile_image, hitboxes + ) + + return tiles + + +def _parse_image_element(image_element: etree.Element) -> objects.Image: + """Parse image element given. + + Returns: + : Color in Arcade's preffered format. + """ + image = objects.Image(image_element.attrib["source"]) + + width_attrib = image_element.attrib.get("width") + height_attrib = image_element.attrib.get("height") + + if width_attrib and height_attrib: + image.size = objects.Size(int(width_attrib), int(height_attrib)) + + try: + image.trans = image_element.attrib["trans"] + except KeyError: + pass + + return image + + +def _parse_properties_element( + properties_element: etree.Element +) -> objects.Properties: + """Adds Tiled property to Properties dict. + + Args: + :name (str): Name of property. + :property_type (str): Type of property. Can be string, int, float, + bool, color or file. Defaults to string. + :value (str): The value of the property. + + Returns: + :Properties: Properties Dict object. + """ + properties: objects.Properties = {} + for property_element in properties_element.findall("./property"): + name = property_element.attrib["name"] + try: + property_type = property_element.attrib["type"] + except KeyError: + # strings do not have an attribute in property elements + property_type = "string" + value = property_element.attrib["value"] + + property_types = ["string", "int", "float", "bool", "color", "file"] + assert ( + property_type in property_types + ), f"Invalid type for property {name}" + + if property_type == "int": + properties[name] = int(value) + elif property_type == "float": + properties[name] = float(value) + elif property_type == "color": + properties[name] = value + elif property_type == "file": + properties[name] = Path(value) + elif property_type == "bool": + if value == "true": + properties[name] = True + else: + properties[name] = False + else: + properties[name] = value + + return properties + + +def _parse_tile_set(tile_set_element: etree.Element) -> objects.TileSet: + """ + Parses a tile set that is embedded into a TMX. + """ + # get all basic attributes + name = tile_set_element.attrib["name"] + max_tile_width = int(tile_set_element.attrib["tilewidth"]) + max_tile_height = int(tile_set_element.attrib["tileheight"]) + max_tile_size = objects.Size(max_tile_width, max_tile_height) + + spacing = None + try: + spacing = int(tile_set_element.attrib["spacing"]) + except KeyError: + pass + + margin = None + try: + margin = int(tile_set_element.attrib["margin"]) + except KeyError: + pass + + tile_count = None + try: + tile_count = int(tile_set_element.attrib["tilecount"]) + except KeyError: + pass + + columns = None + try: + columns = int(tile_set_element.attrib["columns"]) + except KeyError: + pass + + tile_offset = None + tileoffset_element = tile_set_element.find("./tileoffset") + if tileoffset_element is not None: + tile_offset_x = int(tileoffset_element.attrib["x"]) + tile_offset_y = int(tileoffset_element.attrib["y"]) + tile_offset = objects.OrderedPair(tile_offset_x, tile_offset_y) + + grid = None + grid_element = tile_set_element.find("./grid") + if grid_element is not None: + grid_orientation = grid_element.attrib["orientation"] + grid_width = int(grid_element.attrib["width"]) + grid_height = int(grid_element.attrib["height"]) + grid = objects.Grid(grid_orientation, grid_width, grid_height) + + properties = None + properties_element = tile_set_element.find("./properties") + if properties_element is not None: + properties = _parse_properties_element(properties_element) + + terrain_types: Optional[List[objects.Terrain]] = None + terrain_types_element = tile_set_element.find("./terraintypes") + if terrain_types_element is not None: + terrain_types = [] + for terrain in terrain_types_element.findall("./terrain"): + name = terrain.attrib["name"] + terrain_tile = int(terrain.attrib["tile"]) + terrain_types.append(objects.Terrain(name, terrain_tile)) + + image = None + image_element = tile_set_element.find("./image") + if image_element is not None: + image = _parse_image_element(image_element) + + tile_element_list = tile_set_element.findall("./tile") + tiles = _parse_tiles(tile_element_list) + + return objects.TileSet( + name, + max_tile_size, + spacing, + margin, + tile_count, + columns, + tile_offset, + grid, + properties, + image, + terrain_types, + tiles, + ) + + +def parse_tile_map(tmx_file: Union[str, Path]) -> objects.TileMap: + # setting up XML parsing + map_tree = etree.parse(str(tmx_file)) + map_element = map_tree.getroot() + + # positional arguments for TileMap + parent_dir = Path(tmx_file).parent + + version = map_element.attrib["version"] + tiled_version = map_element.attrib["tiledversion"] + orientation = map_element.attrib["orientation"] + render_order = map_element.attrib["renderorder"] + map_width = int(map_element.attrib["width"]) + map_height = int(map_element.attrib["height"]) + map_size = objects.Size(map_width, map_height) + tile_width = int(map_element.attrib["tilewidth"]) + tile_height = int(map_element.attrib["tileheight"]) + tile_size = objects.Size(tile_width, tile_height) + + infinite_attribute = map_element.attrib["infinite"] + infinite = True if infinite_attribute == "true" else False + + next_layer_id = int(map_element.attrib["nextlayerid"]) + next_object_id = int(map_element.attrib["nextobjectid"]) + + # parse all tilesets + tile_sets: Dict[int, objects.TileSet] = {} + tile_set_element_list = map_element.findall("./tileset") + for tile_set_element in tile_set_element_list: + # tiled docs are ambiguous about the 'firstgid' attribute + # current understanding is for the purposes of mapping the layer + # data to the tile set data, add the 'firstgid' value to each + # tile 'id'; this means that the 'firstgid' is specific to each, + # tile set as they pertain to the map, not tile set specific as + # the tiled docs can make it seem + # 'firstgid' is saved beside each TileMap + firstgid = int(tile_set_element.attrib["firstgid"]) + try: + # check if is an external TSX + source = tile_set_element.attrib["source"] + except KeyError: + # the tile set in embedded + name = tile_set_element.attrib["name"] + tile_sets[firstgid] = _parse_tile_set(tile_set_element) + else: + # tile set is external + tile_sets[firstgid] = _parse_external_tile_set( + parent_dir, tile_set_element + ) + + layers = _get_layers(map_element) + + tile_map = objects.TileMap( + parent_dir, + version, + tiled_version, + orientation, + render_order, + map_size, + tile_size, + infinite, + next_layer_id, + next_object_id, + tile_sets, + layers, + ) + + try: + tile_map.hex_side_length = int(map_element.attrib["hexsidelength"]) + except KeyError: + pass + + try: + tile_map.stagger_axis = int(map_element.attrib["staggeraxis"]) + except KeyError: + pass + + try: + tile_map.stagger_index = int(map_element.attrib["staggerindex"]) + except KeyError: + pass + + try: + tile_map.background_color = map_element.attrib["backgroundcolor"] + except KeyError: + pass + + properties_element = map_tree.find("./properties") + if properties_element is not None: + tile_map.properties = _parse_properties_element(properties_element) + + return tile_map diff --git a/setup.py b/setup.py index edb257d..d8b13db 100644 --- a/setup.py +++ b/setup.py @@ -3,38 +3,36 @@ import sys from setuptools import setup BUILD = 0 -VERSION = '0.1' +VERSION = "0.0.1" RELEASE = VERSION -if __name__ == '__main__': - readme = path.join(path.dirname(path.abspath(__file__)), 'README.md') - with open(readme, 'r') as f: +if __name__ == "__main__": + readme = path.join(path.dirname(path.abspath(__file__)), "README.md") + with open(readme, "r") as f: long_desc = f.read() setup( - name='pytiled_parser', - version=RELEASE, - description='Python Library for parsing Tiled Map Editor maps.', - long_description=long_desc, - author='Benjamin Kirkbride', - author_email='BenjaminKirkbride@gmail.com', - license='MIT', - url='https://github.com/Beefy-Swain/pytiled_parser', - download_url='https://github.com/Beefy-Swain/pytiled_parser', - install_requires=[ - 'dataclasses', - ], - packages=['pytiled_parser'], - classifiers=[ - 'Development Status :: 1 - Planning', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - test_suite='tests', - ) + name="pytiled_parser", + version=RELEASE, + description="Python Library for parsing Tiled Map Editor maps.", + long_description=long_desc, + author="Benjamin Kirkbride", + author_email="BenjaminKirkbride@gmail.com", + license="MIT", + url="https://github.com/Beefy-Swain/pytiled_parser", + download_url="https://github.com/Beefy-Swain/pytiled_parser", + install_requires=["dataclasses"], + packages=["pytiled_parser"], + classifiers=[ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + test_suite="tests", + ) diff --git a/test/output.py b/test/output.py index 2b5305f..7190a30 100644 --- a/test/output.py +++ b/test/output.py @@ -1,25 +1,20 @@ { 'background_color': None, - 'height': 6, 'hex_side_length': None, 'infinite': False, - 'layers': [ Layer(width=8, height=6, data=[[1, 2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12, 13, 14, 15, 16], [17, 18, 19, 20, 21, 22, 23, 24], [25, 26, 27, 28, 29, 30, 31, 32], [33, 34, 35, 36, 37, 38, 39, 40], [41, 42, 43, 44, 45, 46, 47, 48]], id=1, name='Tile Layer 1', offset=OrderedPair(x=0, y=0), opacity=255, properties=None)], - 'next_layer_id': 2, - 'next_object_id': 1, + 'layers': [ TileLayer(id=1, name='Tile Layer 1', offset=None, opacity=None, properties=None, size=Size(width=10, height=10), data=[[1, 2, 3, 4, 5, 6, 7, 8, 30, 30], [9, 10, 11, 12, 13, 14, 15, 16, 30, 30], [17, 18, 19, 20, 21, 22, 23, 24, 30, 30], [25, 26, 27, 28, 29, 30, 31, 32, 30, 30], [33, 34, 35, 36, 37, 38, 39, 40, 30, 30], [41, 42, 43, 44, 45, 46, 47, 48, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30]]), + TileLayer(id=2, name='Tile Layer 2', offset=None, opacity=0.5, properties=None, size=Size(width=10, height=10), data=[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 46, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 6, 7, 7, 7, 7, 7, 8, 0], [0, 0, 14, 15, 15, 15, 15, 15, 16, 0], [0, 0, 22, 23, 23, 23, 23, 23, 24, 0]]), + LayerGroup(id=3, name='Group 1', offset=None, opacity=None, properties={'bool property': True}, layers=[TileLayer(id=5, name='Tile Layer 4', offset=OrderedPair(x=49.0, y=-50.0), opacity=None, properties=None, size=Size(width=10, height=10), data=[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 31, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), TileLayer(id=4, name='Tile Layer 3', offset=None, opacity=None, properties=None, size=Size(width=10, height=10), data=[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 2, 3, 0, 0, 0, 0, 0, 0, 0], [9, 10, 11, 0, 0, 0, 0, 0, 0, 0], [17, 18, 19, 0, 0, 0, 0, 0, 0, 0]])]), + ObjectLayer(id=6, name='Object Layer 1', offset=OrderedPair(x=4.66667, y=-4.33333), opacity=0.9, properties=None, tiled_objects=[TiledObject(id=1, location=OrderedPair(x=200.25, y=210.75), size=Size(width=47.25, height=25.0), rotation=15, opacity=1, name='rectangle 1', type='rectangle type', properties=None, template=None), TiledObject(id=2, location=OrderedPair(x=252.5, y=87.75), size=Size(width=0, height=0), rotation=-21, opacity=1, name='polygon 1', type='polygon type', properties=None, template=None), TiledObject(id=3, location=OrderedPair(x=198.75, y=102.5), size=Size(width=17.75, height=14.25), rotation=0, opacity=1, name='elipse 1', type='elipse type', properties=None, template=None), TiledObject(id=4, location=OrderedPair(x=174.25, y=186.0), size=Size(width=0, height=0), rotation=0, opacity=1, name='point 1', type='point type', properties=None, template=None), TiledObject(id=7, location=OrderedPair(x=11.3958, y=48.5833), size=Size(width=107.625, height=27.25), rotation=0, opacity=1, name='insert text 1', type='insert text type', properties=None, template=None), TiledObject(id=6, location=OrderedPair(x=47.25, y=72.5), size=Size(width=47.0, height=53.0), rotation=31, opacity=1, name='inserted tile 1', type='inserted tile type', properties={'tile property bool': True}, template=None), TiledObject(id=8, location=OrderedPair(x=144.667, y=112.0), size=Size(width=0, height=0), rotation=0, opacity=1, name='polyline 1', type='polyline type', properties=None, template=None), TiledObject(id=9, location=OrderedPair(x=69.8333, y=168.333), size=Size(width=0, height=0), rotation=0, opacity=1, name='polygon 2', type='polygon type', properties=None, template=None)], color=Color(red=0, green=0, blue=0, alpha=255), draw_order='index')], + 'map_size': Size(width=10, height=10), + 'next_layer_id': 16, + 'next_object_id': 10, 'orientation': 'orthogonal', - 'parent_dir': PosixPath('/home/ben/Projects/pytiled_parser/pytiled_parser-venv/pytiled_parser/tests/test_data'), - 'properties': { 'bool property - false': False, - 'bool property - true': True, - 'color property': Color(red=73, green=252, blue=255, alpha=255), - 'file property': PosixPath('/var/log/syslog'), - 'float property': 1.23456789, - 'int property': 13, - 'string property': 'Hello, World!!'}, + 'parent_dir': PosixPath('/home/benk/Projects/pytiled_parser/venv/pytiled_parser/tests/test_data'), + 'properties': None, 'render_order': 'right-down', 'stagger_axis': None, 'stagger_index': None, - 'tile_height': 32, - 'tile_sets': { 1: TileSet(name='tile_set_image', max_tile_size=OrderedPair(x=32, y=32), spacing=1, margin=1, tile_count=48, columns=8, tile_offset=None, grid=None, properties=None, image=Image(source='images/tmw_desert_spacing.png', trans=None, width=265, height=199), terrain_types=None, tiles={})}, - 'tile_width': 32, + 'tile_sets': { 1: TileSet(name='tile_set_image', max_tile_size=Size(width=32, height=32), spacing=1, margin=1, tile_count=48, columns=8, tile_offset=None, grid=None, properties=None, image=Image(source='images/tmw_desert_spacing.png', size=Size(width=265, height=199), trans=None), terrain_types=None, tiles={})}, + 'tile_size': Size(width=32, height=32), 'tiled_version': '1.2.3', - 'version': '1.2', - 'width': 8} + 'version': '1.2'} diff --git a/test/test_tiled.py b/test/test_tiled.py index 431e072..235d112 100644 --- a/test/test_tiled.py +++ b/test/test_tiled.py @@ -10,7 +10,7 @@ pp = pprint.PrettyPrinter(indent=4, compact=True, width=100) pp = pp.pprint -MAP_NAME = '/home/ben/Projects/pytiled_parser/pytiled_parser-venv/pytiled_parser/tests/test_data/test_map_simple.tmx' +MAP_NAME = "/home/benk/Projects/pytiled_parser/venv/pytiled_parser/tests/test_data/test_map_image_tile_set.tmx" map = pytiled_parser.parse_tile_map(MAP_NAME) diff --git a/tests/test_data/test_map_simple_hitboxes.tmx b/tests/test_data/test_map_simple_hitboxes.tmx new file mode 100644 index 0000000..f197ed6 --- /dev/null +++ b/tests/test_data/test_map_simple_hitboxes.tmx @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + +1,2,3,4,5,6,7,8, +9,10,11,12,13,14,15,16, +17,18,19,20,21,22,23,24, +25,26,27,28,29,30,31,32, +33,34,35,36,37,38,39,40, +41,42,43,44,45,46,47,48 + + + diff --git a/tests/test_data/test_map_simple_meme.tmx b/tests/test_data/test_map_simple_meme.tmx new file mode 100644 index 0000000..bc47bdc --- /dev/null +++ b/tests/test_data/test_map_simple_meme.tmx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + H4sIAAAAAAAAAw3DBRKCQAAAwDMRA7BQLMTE9v+vY3dmWyGEth279uwbOTB26MixExNTM6fOnLtwae7KtYUbt+7ce7D0aOXJsxev3rxb+/Dpy7cfv/782wAcvDirwAAAAA== + + + diff --git a/tests/test_data/tile_set_image_hitboxes.tsx b/tests/test_data/tile_set_image_hitboxes.tsx new file mode 100644 index 0000000..6d65375 --- /dev/null +++ b/tests/test_data/tile_set_image_hitboxes.tsx @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/unit2/test_parser.py b/tests/unit2/test_parser.py new file mode 100644 index 0000000..3d58ab1 --- /dev/null +++ b/tests/unit2/test_parser.py @@ -0,0 +1,170 @@ +import pytest + +import xml.etree.ElementTree as etree + +from contextlib import contextmanager +from typing import Callable, List, Optional, Tuple + +from pytiled_parser import objects, xml_parser, utilities + + +@contextmanager +def does_not_raise(): + yield + + +def _get_root_element(xml: str) -> etree.Element: + return etree.fromstring(xml) + + +layer_data = [ + ( + '' + "", + (int(1), "Tile Layer 1", None, None, None), + ), + ( + '' + "", + (int(2), "Tile Layer 2", None, float(0.5), None), + ), + ( + '' + "" + "" + "", + ( + int(5), + "Tile Layer 4", + objects.OrderedPair(49, -50), + None, + "properties", + ), + ), +] + + +@pytest.mark.parametrize("xml,expected", layer_data) +def test_parse_layer(xml, expected, monkeypatch): + def mockreturn(properties): + return "properties" + + monkeypatch.setattr(xml_parser, "_parse_properties_element", mockreturn) + + assert xml_parser._parse_layer(_get_root_element(xml)) == expected + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("#001122", (0x00, 0x11, 0x22, 0xFF)), + ("001122", (0x00, 0x11, 0x22, 0xFF)), + ("#FF001122", (0x00, 0x11, 0x22, 0xFF)), + ("FF001122", (0x00, 0x11, 0x22, 0xFF)), + ("FF001122", (0x00, 0x11, 0x22, 0xFF)), + ], +) +def test_color_parsing(test_input, expected): + """ + Tiled has a few different types of color representations. + """ + assert utilities.parse_color(test_input) == expected + + +data_csv = [ + ( + "\n1,2,3,4,5,6,7,8,\n" + "9,10,11,12,13,14,15,16,\n" + "17,18,19,20,21,22,23,24,\n" + "25,26,27,28,29,30,31,32,\n" + "33,34,35,36,37,38,39,40,\n" + "41,42,43,44,45,46,47,48\n", + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ], + ), + ("\n0,0,0,0,0\n", [[0, 0, 0, 0, 0]]), +] + + +@pytest.mark.parametrize("data_csv,expected", data_csv) +def test_decode_csv_data(data_csv, expected): + assert xml_parser._decode_csv_data(data_csv) == expected + + +data_base64 = [ + ( + "AQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEAAAASAAAAEwAAABQAAAAVAAAAFgAAABcAAAAYAAAAGQAAABoAAAAbAAAAHAAAAB0AAAAeAAAAHwAAACAAAAAhAAAAIgAAACMAAAAkAAAAJQAAACYAAAAnAAAAKAAAACkAAAAqAAAAKwAAACwAAAAtAAAALgAAAC8AAAAwAAAA", + 8, + None, + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ], + does_not_raise(), + ), + ( + "eJwNwwUSgkAAAMAzEQOwUCzExPb/r2N3ZlshhLYdu/bsGzkwdujIsRMTUzOnzpy7cGnuyrWFG7fu3Huw9GjlybMXr968W/vw6cu3H7/+/NsAMw8EmQ==", + 8, + "zlib", + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ], + does_not_raise(), + ), + ( + "H4sIAAAAAAAAAw3DBRKCQAAAwDMRA7BQLMTE9v+vY3dmWyGEth279uwbOTB26MixExNTM6fOnLtwae7KtYUbt+7ce7D0aOXJsxev3rxb+/Dpy7cfv/782wAcvDirwAAAAA==", + 8, + "gzip", + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ], + does_not_raise(), + ), + ( + "SGVsbG8gV29ybGQh", + 8, + "lzma", + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ], + pytest.raises(ValueError), + ), +] + + +@pytest.mark.parametrize( + "data_base64,width,compression,expected,raises", data_base64 +) +def test_decode_base64_data( + data_base64, width, compression, expected, raises +): + with raises: + assert ( + xml_parser._decode_base64_data(data_base64, width, compression) + == expected + ) diff --git a/tests/unit2/test_pytiled_parser.py b/tests/unit2/test_pytiled_parser_integration.py.bax similarity index 65% rename from tests/unit2/test_pytiled_parser.py rename to tests/unit2/test_pytiled_parser_integration.py.bax index a1044e1..904548a 100644 --- a/tests/unit2/test_pytiled_parser.py +++ b/tests/unit2/test_pytiled_parser_integration.py.bax @@ -15,7 +15,9 @@ def test_map_simple(): """ TMX with a very simple spritesheet tile set and some properties. """ - map = pytiled_parser.parse_tile_map(Path("../test_data/test_map_simple.tmx")) + map = pytiled_parser.parse_tile_map( + Path("../test_data/test_map_simple.tmx") + ) # map # unsure how to get paths to compare propperly @@ -37,13 +39,13 @@ def test_map_simple(): assert map.background_color == None assert map.properties == { - "bool property - false": False, - "bool property - true": True, - "color property": (0x49, 0xfc, 0xff, 0xff), - "file property": Path("/var/log/syslog"), - "float property": 1.23456789, - "int property": 13, - "string property": "Hello, World!!" + "bool property - false": False, + "bool property - true": True, + "color property": (0x49, 0xFC, 0xFF, 0xFF), + "file property": Path("/var/log/syslog"), + "float property": 1.23456789, + "int property": 13, + "string property": "Hello, World!!", } # tileset @@ -59,7 +61,8 @@ def test_map_simple(): # unsure how to get paths to compare propperly assert str(map.tile_sets[1].image.source) == ( - "images/tmw_desert_spacing.png") + "images/tmw_desert_spacing.png" + ) assert map.tile_sets[1].image.trans == None assert map.tile_sets[1].image.size == (265, 199) @@ -67,28 +70,31 @@ def test_map_simple(): assert map.tile_sets[1].tiles == {} # layers - assert map.layers[0].data == [[1,2,3,4,5,6,7,8], - [9,10,11,12,13,14,15,16], - [17,18,19,20,21,22,23,24], - [25,26,27,28,29,30,31,32], - [33,34,35,36,37,38,39,40], - [41,42,43,44,45,46,47,48]] + assert map.layers[0].data == [ + [1, 2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31, 32], + [33, 34, 35, 36, 37, 38, 39, 40], + [41, 42, 43, 44, 45, 46, 47, 48], + ] assert map.layers[0].id == 1 assert map.layers[0].name == "Tile Layer 1" - assert map.layers[0].offset == (0, 0) - assert map.layers[0].opacity == 0xFF + assert map.layers[0].offset == None + assert map.layers[0].opacity == None assert map.layers[0].properties == None assert map.layers[0].size == (8, 6) @pytest.mark.parametrize( - "test_input,expected", [ - ("#001122", (0x00, 0x11, 0x22, 0xff)), - ("001122", (0x00, 0x11, 0x22, 0xff)), - ("#FF001122", (0x00, 0x11, 0x22, 0xff)), - ("FF001122", (0x00, 0x11, 0x22, 0xff)), - ("FF001122", (0x00, 0x11, 0x22, 0xff)), - ] + "test_input,expected", + [ + ("#001122", (0x00, 0x11, 0x22, 0xFF)), + ("001122", (0x00, 0x11, 0x22, 0xFF)), + ("#FF001122", (0x00, 0x11, 0x22, 0xFF)), + ("FF001122", (0x00, 0x11, 0x22, 0xFF)), + ("FF001122", (0x00, 0x11, 0x22, 0xFF)), + ], ) def test_color_parsing(test_input, expected): """