"""Handle XML parsing for TMX files""" import base64 import functools import gzip import re import xml.etree.ElementTree as etree import zlib from pathlib import Path from typing import Callable, Dict, List, Optional, Tuple, Union import pytiled_parser.objects as objects import pytiled_parser.typing_helpers as TH from pytiled_parser.utilities import parse_color def _decode_base64_data( data_text: str, layer_width: int, compression: Optional[str] = None ) -> objects.TileLayerGrid: """Decode base64 data. Args: data_text: Data to be decoded. layer_width: Width of each layer in tiles. compression: The type of compression for the data. Raises: ValueError: If compression type is unsupported. Returns: objects.TileLayerGrid: Tile grid. """ tile_grid: objects.TileLayerGrid = [[]] 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 not byte_count % 4: byte_count = 0 int_count += 1 tile_grid[row_count].append(int_value) int_value = 0 if not int_count % layer_width: row_count += 1 tile_grid.append([]) tile_grid.pop() return tile_grid def _decode_csv_data(data_text: str) -> objects.TileLayerGrid: """Decodes csv encoded layer data. Args: data_text: Data to be decoded. Returns: objects.TileLayerGrid: Tile grid. """ tile_grid = [] lines: List[str] = data_text.split("\n") # remove erroneous 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 # I'm not sure if this is the best way to do this TileLayerDecoder = Union[ Callable[[str], objects.TileLayerGrid], Callable[[str, int, Optional[str]], objects.TileLayerGrid], ] def _decode_tile_layer_data( element: etree.Element, layer_width: int, encoding: str, compression: Optional[str] = None, ) -> objects.TileLayerGrid: """Decodes tile layer data or chunk data. See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#tmx-data Args: element: Element to have text decoded. layer_width: Number of tiles per column in this layer. Used for determining when to cut off a row when decoding base64 encoding layers. encoding: Encoding format of the layer data. compression: Compression format of the layer data. Raises: ValueError: Encoding type is not supported. Returns: objects.TileLayerGrid: Tile grid. """ data_text: str = element.text # type: ignore if encoding == "csv": return _decode_csv_data(data_text) if encoding == "base64": return _decode_base64_data(data_text, layer_width, compression) raise ValueError(f"{encoding} is not a supported encoding") 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: Data element to parse. layer_width: 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_tile_layer_data( chunk_element, layer_width, encoding, compression ) chunks.append(objects.Chunk(location, width, height, layer_data)) return chunks return _decode_tile_layer_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 = layer_element.attrib.get("offsetx") offset_y = layer_element.attrib.get("offsety") if offset_x and offset_y: assert TH.is_float(offset_x) assert TH.is_float(offset_y) offset = objects.OrderedPair(float(offset_x), float(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. Raises: ValueError: Element has no child data element. 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") assert data_element is not None layer_data: objects.LayerData = _parse_data(data_element, width) return objects.TileLayer( id_=id_, name=name, offset=offset, opacity=opacity, properties=properties, size=size, layer_data=layer_data, ) def _parse_tiled_objects( object_elements: List[etree.Element], ) -> List[objects.TiledObject]: """Parses objects found in a '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_=id_, location=location) try: tiled_object.gid = int(object_element.attrib["gid"]) except KeyError: tiled_object.gid = None try: # If any dimension is provided, they both will be width = float(object_element.attrib["width"]) height = float(object_element.attrib["height"]) tiled_object.size = objects.Size(width, height) except KeyError: pass try: tiled_object.opacity = float(object_element.attrib["opacity"]) except KeyError: pass try: tiled_object.rotation = float(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. See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#objectgroup Args: element: Element to be parsed. Returns: ObjectLayer: The object layer object. """ id_, name, offset, opacity, properties = _parse_layer(element) color = None try: color = element.attrib["color"] except KeyError: pass draw_order = None try: draw_order = element.attrib["draworder"] except KeyError: pass tiled_objects = _parse_tiled_objects(element.findall("./object")) return objects.ObjectLayer( id_=id_, name=name, offset=offset, opacity=opacity, properties=properties, color=color, draw_order=draw_order, tiled_objects=tiled_objects, ) def _parse_layer_group(element: etree.Element,) -> objects.LayerGroup: """Parse the objectgroup element given. Args: element: Element to be parsed. Returns: LayerGroup: The layer group object. """ id_, name, offset, opacity, properties = _parse_layer(element) layers = _get_layers(element) return objects.LayerGroup( id_=id_, name=name, offset=offset, opacity=opacity, properties=properties, layers=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 if layer_tag == "objectgroup": return _parse_object_layer if layer_tag == "group": return _parse_layer_group 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. Args: parent_dir: Directory that TMX is in. tile_set_element: Tile set element. Returns: objects.Tileset: The tileset being parsed. """ source = Path(tile_set_element.attrib["source"]) resolved_path = parent_dir / source tile_set_tree = etree.parse(str(parent_dir / Path(source))).getroot() parsed_tile_set = _parse_tile_set(tile_set_tree) parsed_tile_set.tsx_file = resolved_path parsed_tile_set.parent_dir = resolved_path.parent return parsed_tile_set def _parse_points(point_string: str) -> List[objects.OrderedPair]: str_pairs = point_string.split(" ") points = [] for str_pair in str_pairs: xys = str_pair.split(",") x = float(xys[0]) y = float(xys[1]) points.append((x, y)) return points def _parse_tiles(tile_element_list: List[etree.Element]) -> Dict[int, objects.Tile]: """Parse a list of tile elements. Args: tile_element_list: List of tile elements. Returns: Dict[int, objects.Tile]: Dictionary containing Tile objects by their ID. """ 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 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 refers to a corner for corner in terrain_list_attrib: if not corner: terrain_list.append(None) else: terrain_list.append(int(corner)) terrain = objects.TileTerrain(*terrain_list) # tile element optional sub-elements properties: Optional[List[objects.Property]] = None tile_properties_element = tile_element.find("./properties") if tile_properties_element: properties = [] property_list = tile_properties_element.findall("./property") for property_ in property_list: name = property_.attrib["name"] value = property_.attrib["value"] obj = objects.Property(name, value) properties.append(obj) # 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 refers to the Tile.id of the animation frame animated_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(animated_id, duration)) # tile element optional sub-elements objectgroup: Optional[List[objects.TiledObject]] = None objectgroup_element = tile_element.find("./objectgroup") if objectgroup_element: objectgroup = [] object_list = objectgroup_element.findall("./object") for object in object_list: my_id = object.attrib["id"] my_x = float(object.attrib["x"]) my_y = float(object.attrib["y"]) if "width" in object.attrib: my_width = float(object.attrib["width"]) else: my_width = None if "height" in object.attrib: my_height = float(object.attrib["height"]) else: my_height = None # This is where it would be nice if we could assume a walrus # operator was part of our Python distribution. my_object = None polygon = object.findall("./polygon") if polygon and len(polygon) > 0: points = _parse_points(polygon[0].attrib["points"]) my_object = objects.PolygonObject( id_=my_id, location=(my_x, my_y), size=(my_width, my_height), points=points, ) if my_object is None: polyline = object.findall("./polyline") if polyline and len(polyline) > 0: points = _parse_points(polyline[0].attrib["points"]) my_object = objects.PolylineObject( id_=my_id, location=(my_x, my_y), size=(my_width, my_height), points=points, ) if my_object is None: ellipse = object.findall("./ellipse") if ellipse and len(ellipse): my_object = objects.ElipseObject( id_=my_id, location=(my_x, my_y), size=(my_width, my_height) ) if my_object is None: if "template" in object.attrib: print( "Warning, this .tmx file is using an unsupported" "'template' attribute. Ignoring." ) continue if my_object is None: my_object = objects.RectangleObject( id_=my_id, location=(my_x, my_y), size=(my_width, my_height) ) objectgroup.append(my_object) # if this is None, then the Tile is part of a spritesheet image = None image_element = tile_element.find("./image") if image_element is not None: image = _parse_image_element(image_element) # print(f"Adding '{id_}', {image}, {objectgroup}") tiles[id_] = objects.Tile( id_=id_, type_=_type, terrain=terrain, animation=animation, image=image, properties=properties, tileset=None, objectgroup=objectgroup, ) return tiles def _parse_image_element(image_element: etree.Element) -> objects.Image: """Parse image element given. Args: image_element (etree.Element): Image element to be parsed. Returns: objects.Image: FIXME what is this? """ # FIXME doc 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: # FIXME: wtf is this pseudo 'attributes' section? """Adds Tiled property to Properties dict. Each property element has a number of attributes: name: Name of property. property_type: Type of property. Can be string, int, float, bool, color or file. Defaults to string. value: The value of the property. Args: properties_element: Element to be parsed. Returns: objects.Properties: Dict of the property values by property name. """ 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. Args: tile_set_element: Element to be parsed. Returns: objects.TileSet: Tile Set from element. """ # 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) tileset = objects.TileSet( name, max_tile_size, spacing, margin, tile_count, columns, tile_offset, grid, properties, image, terrain_types, tiles, ) # Go back and create a circular link so tiles know what tileset they are # part of. Needed for animation. for id_, tile in tiles.items(): tile.tileset = tileset return tileset def _get_tile_sets(map_element: etree.Element, parent_dir: Path) -> objects.TileSetDict: """Get tile sets. Args: map_element: Element to be parsed. parent_dir: Directory that TMX is in. Returns: objects.TileSetDict: Dict of tile sets in the TMX by first_gid """ # parse all tilesets tile_sets: objects.TileSetDict = {} 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' the key for each TileMap first_gid = int(tile_set_element.attrib["firstgid"]) try: # check if is an external TSX source = tile_set_element.attrib["source"] except KeyError: # the tile set is embedded name = tile_set_element.attrib["name"] tile_sets[first_gid] = _parse_tile_set(tile_set_element) else: # tile set is external tile_sets[first_gid] = _parse_external_tile_set( parent_dir, tile_set_element ) return tile_sets def parse_tile_map(tmx_file: Union[str, Path]) -> objects.TileMap: """Parse tile map. Args: tmx_file: TMX file to be parsed. Returns: objects.TileMap: TileMap object generated from the TMX file provided. """ # 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 = bool(infinite_attribute == "1") if "nextlayerid" in map_element.attrib: next_layer_id = int(map_element.attrib["nextlayerid"]) else: next_layer_id = None if "nextobjectid" in map_element.attrib: next_object_id = int(map_element.attrib["nextobjectid"]) else: next_object_id = None tile_sets = _get_tile_sets(map_element, parent_dir) layers = _get_layers(map_element) tile_map = objects.TileMap( parent_dir, tmx_file, 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 = map_element.attrib["staggeraxis"] except KeyError: pass try: tile_map.stagger_index = map_element.attrib["staggerindex"] except KeyError: pass try: color = parse_color(map_element.attrib["backgroundcolor"]) tile_map.background_color = (color.red, color.green, color.blue) 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