diff --git a/pytiled_parser/layer.py b/pytiled_parser/layer.py index 8ad76469..83ef19a5 100644 --- a/pytiled_parser/layer.py +++ b/pytiled_parser/layer.py @@ -117,6 +117,9 @@ class TileLayer(Layer): tile IDs for the layer (only populaed for non-infinite maps) """ + encoding: str = "csv" + compression: str = "" + chunks: Optional[List[Chunk]] = None data: Optional[List[List[int]]] = None diff --git a/pytiled_parser/parsers/json/layer.py b/pytiled_parser/parsers/json/layer.py index 9682fc4e..56801187 100644 --- a/pytiled_parser/parsers/json/layer.py +++ b/pytiled_parser/parsers/json/layer.py @@ -4,6 +4,7 @@ import base64 import gzip import importlib.util +import struct import zlib from pathlib import Path from typing import Any, List, Optional, Union, cast @@ -21,9 +22,11 @@ ) from pytiled_parser.parsers.json.properties import RawProperty from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.properties import serialize as serialize_properties from pytiled_parser.parsers.json.tiled_object import RawObject from pytiled_parser.parsers.json.tiled_object import parse as parse_object -from pytiled_parser.util import parse_color +from pytiled_parser.parsers.json.tiled_object import serialize as serialize_object +from pytiled_parser.util import parse_color, serialize_color # This optional zstd include is basically impossible to make a sensible test # for both ways. It's been tested manually, is unlikely to change or be effected @@ -117,6 +120,10 @@ def _convert_raw_tile_layer_data(data: List[int], layer_width: int) -> List[List return tile_grid +def _convert_tile_layer_data_to_raw(data: List[List[int]]) -> List[int]: + return [i for sub in data for i in sub] + + def _decode_tile_layer_data( data: str, compression: str, layer_width: int ) -> List[List[int]]: @@ -148,23 +155,45 @@ def _decode_tile_layer_data( else: unzipped_data = unencoded_data - tile_grid: List[int] = [] - - byte_count = 0 - int_count = 0 - int_value = 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.append(int_value) - int_value = 0 + tile_grid: List[int] = list( + struct.unpack("<%dL" % (len(unzipped_data) / 4), unzipped_data) + ) return _convert_raw_tile_layer_data(tile_grid, layer_width) +def _encode_tile_layer_data( + data: List[List[int]], compression: Optional[str] = None +) -> str: + """Encode a Base64 string of tile data. Optionally supports gzip, zlib, and zstd compression. + Args: + data: Tile data in the form of a 2 dimensional list of ints + compression: Either zlib, gzip, zstd, or empty. If empty no compression is performed. + Returns: + str: The encoded and compressed data + Raises: + ValueError: For an unsupported compression type + """ + flattened = [i for sub in data for i in sub] + data_bytes = struct.pack("<%dL" % len(flattened), *flattened) + + if compression == "zlib": + compressed = zlib.compress(data_bytes) + elif compression == "gzip": + compressed = gzip.compress(data_bytes) + elif compression == "zstd" and zstd is None: + raise ValueError( + "zstd compression support is not installed." + "To install use 'pip install pytiled-parser[zstd]'" + ) + elif compression == "zstd": # pragma: no cover + compressed = zstd.compress(data_bytes) + else: + compressed = data_bytes + + return base64.b64encode(compressed).decode("UTF-8") + + def _parse_chunk( raw_chunk: RawChunk, encoding: Optional[str] = None, @@ -200,6 +229,24 @@ def _parse_chunk( return chunk +def _serialize_chunk( + chunk: Chunk, encoding: Optional[str] = None, compression: Optional[str] = None +) -> RawChunk: + if encoding == "base64": + assert isinstance(compression, str) + data = _encode_tile_layer_data(chunk.data, compression) + else: + data = _convert_tile_layer_data_to_raw(chunk.data) + + return { + "data": data, + "width": int(chunk.size.width), + "height": int(chunk.size.height), + "x": int(chunk.coordinates.x), + "y": int(chunk.coordinates.y), + } + + def _parse_common(raw_layer: RawLayer) -> Layer: """Create a Layer containing all the attributes common to all layer types. @@ -260,6 +307,55 @@ def _parse_common(raw_layer: RawLayer) -> Layer: return common +def _serialize_common(layer: Layer, layer_type: str) -> RawLayer: + serialized: RawLayer = { + "name": layer.name, + "opacity": layer.opacity, + "visible": layer.visible, + "x": 0, + "y": 0, + "type": layer_type, + } + + if layer.id: + serialized["id"] = layer.id + + if layer.coordinates != OrderedPair(0, 0): + serialized["startx"] = int(layer.coordinates.x) + serialized["starty"] = int(layer.coordinates.y) + + if layer.size: + serialized["width"] = int(layer.size.width) + serialized["height"] = int(layer.size.height) + + if layer.offset != OrderedPair(0, 0): + serialized["offsetx"] = layer.offset.x + serialized["offsety"] = layer.offset.y + + if layer.properties: + serialized["properties"] = serialize_properties(layer.properties) + + if layer.class_: + serialized["class"] = layer.class_ + + if layer.parallax_factor.x != 1.0: + serialized["parallaxx"] = layer.parallax_factor.x + + if layer.parallax_factor.y != 1.0: + serialized["parallaxy"] = layer.parallax_factor.y + + if layer.tint_color: + serialized["tintcolor"] = serialize_color(layer.tint_color) + + if layer.repeat_x: + serialized["repeatx"] = layer.repeat_x + + if layer.repeat_y: + serialized["repeaty"] = layer.repeat_y + + return serialized + + def _parse_tile_layer(raw_layer: RawLayer) -> TileLayer: """Parse the raw_layer to a TileLayer. @@ -275,6 +371,8 @@ def _parse_tile_layer(raw_layer: RawLayer) -> TileLayer: tile_layer.chunks = [] for chunk in raw_layer["chunks"]: if raw_layer.get("encoding") is not None: + tile_layer.compression = raw_layer["compression"] + tile_layer.encoding = raw_layer["encoding"] tile_layer.chunks.append( _parse_chunk(chunk, raw_layer["encoding"], raw_layer["compression"]) ) @@ -283,6 +381,8 @@ def _parse_tile_layer(raw_layer: RawLayer) -> TileLayer: if raw_layer.get("data") is not None: if raw_layer.get("encoding") is not None: + tile_layer.compression = raw_layer["compression"] + tile_layer.encoding = raw_layer["encoding"] tile_layer.data = _decode_tile_layer_data( data=cast(str, raw_layer["data"]), compression=raw_layer["compression"], @@ -296,6 +396,31 @@ def _parse_tile_layer(raw_layer: RawLayer) -> TileLayer: return tile_layer +def _serialize_tile_layer(layer: TileLayer) -> RawLayer: + serialized = _serialize_common(layer, "tilelayer") + + if layer.chunks: + raw_chunks: List[RawChunk] = [] + for chunk in layer.chunks: + raw_chunks.append( + _serialize_chunk(chunk, layer.encoding, layer.compression) + ) + serialized["chunks"] = raw_chunks + + if layer.data: + if layer.encoding == "base64": + raw_data = _encode_tile_layer_data(layer.data, layer.compression) + else: + raw_data = _convert_tile_layer_data_to_raw(layer.data) + serialized["data"] = raw_data + + if layer.encoding != "csv": + serialized["encoding"] = layer.encoding + serialized["compression"] = layer.compression + + return serialized + + def _parse_object_layer( raw_layer: RawLayer, parent_dir: Optional[Path] = None, @@ -319,6 +444,18 @@ def _parse_object_layer( ) +def _serialize_object_layer(layer: ObjectLayer) -> RawLayer: + serialized = _serialize_common(layer, "objectgroup") + + objects = [] + for obj in layer.tiled_objects: + objects.append(serialize_object(obj)) + serialized["objects"] = objects + serialized["draworder"] = layer.draw_order + + return serialized + + def _parse_image_layer(raw_layer: RawLayer) -> ImageLayer: """Parse the raw_layer to an ImageLayer. @@ -338,6 +475,19 @@ def _parse_image_layer(raw_layer: RawLayer) -> ImageLayer: return image_layer +def _serialize_image_layer(layer: ImageLayer) -> RawLayer: + serialized = _serialize_common(layer, "imagelayer") + + # TODO: This is an absolute path, need to figure out how to handle + # relative filepaths for serialization + serialized["image"] = str(layer.image) + + if layer.transparent_color: + serialized["transparentcolor"] = serialize_color(layer.transparent_color) + + return serialized + + def _parse_group_layer( raw_layer: RawLayer, parent_dir: Optional[Path] = None ) -> LayerGroup: @@ -357,6 +507,20 @@ def _parse_group_layer( return LayerGroup(layers=layers, **_parse_common(raw_layer).__dict__) +def _serialize_group_layer(layer: LayerGroup) -> RawLayer: + serialized = _serialize_common(layer, "group") + + raw_layers: List[RawLayer] = [] + + for child_layer in layer.layers: + raw_layers.append(serialize(child_layer)) + + if raw_layers: + serialized["layers"] = raw_layers + + return serialized + + def parse( raw_layer: RawLayer, parent_dir: Optional[Path] = None, @@ -387,3 +551,16 @@ def parse( return _parse_tile_layer(raw_layer) raise RuntimeError(f"An invalid layer type of {type_} was supplied") + + +def serialize(layer: Layer) -> RawLayer: + if isinstance(layer, TileLayer): + return _serialize_tile_layer(layer) + elif isinstance(layer, ImageLayer): + return _serialize_image_layer(layer) + elif isinstance(layer, ObjectLayer): + return _serialize_object_layer(layer) + elif isinstance(layer, LayerGroup): + return _serialize_group_layer(layer) + + raise AttributeError("Tried to serialize an unknown layer type.") diff --git a/pytiled_parser/parsers/json/properties.py b/pytiled_parser/parsers/json/properties.py index 62b2e17b..bb010d64 100644 --- a/pytiled_parser/parsers/json/properties.py +++ b/pytiled_parser/parsers/json/properties.py @@ -6,8 +6,9 @@ from typing_extensions import TypedDict +from pytiled_parser.common_types import Color from pytiled_parser.properties import Properties, Property -from pytiled_parser.util import parse_color +from pytiled_parser.util import parse_color, serialize_color RawValue = Union[float, str, bool] @@ -50,3 +51,38 @@ def parse(raw_properties: List[RawProperty]) -> Properties: final[raw_property["name"]] = value return final + + +def serialize(properties: Properties) -> List[RawProperty]: + """Serialize a Properties object into a list of RawProperty objects. + + Args: + properties: The properties to be serialized. + + Returns: + List[RawProperty]: The serialized RawProperty list + """ + + final: List[RawProperty] = [] + + for name, prop in properties.items(): + prop_type = "" + if isinstance(prop, Path): + prop_type = "file" + prop = str(prop) + elif isinstance(prop, Color): + prop_type = "color" + prop = serialize_color(prop) + elif isinstance(prop, str): + prop_type = "string" + elif isinstance(prop, float): + prop_type = "float" + elif isinstance(prop, int): + prop_type = "int" + elif isinstance(prop, bool): + prop_type = "bool" + + raw: RawProperty = {"name": name, "type": prop_type, "value": prop} + final.append(raw) + + return final diff --git a/pytiled_parser/parsers/json/tiled_object.py b/pytiled_parser/parsers/json/tiled_object.py index 527cf15e..fe728c10 100644 --- a/pytiled_parser/parsers/json/tiled_object.py +++ b/pytiled_parser/parsers/json/tiled_object.py @@ -6,9 +6,10 @@ from typing_extensions import TypedDict -from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.common_types import Color, OrderedPair, Size from pytiled_parser.parsers.json.properties import RawProperty from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.properties import serialize as serialize_properties from pytiled_parser.tiled_object import ( Ellipse, Point, @@ -19,7 +20,7 @@ Tile, TiledObject, ) -from pytiled_parser.util import load_object_template, parse_color +from pytiled_parser.util import load_object_template, parse_color, serialize_color RawText = TypedDict( "RawText", @@ -106,6 +107,25 @@ def _parse_common(raw_object: RawObject) -> TiledObject: return common +def _serialize_common(obj: TiledObject) -> RawObject: + common: RawObject = { + "id": obj.id, + "x": obj.coordinates.x, + "y": obj.coordinates.y, + "visible": obj.visible, + "width": obj.size.width, + "height": obj.size.height, + "rotation": obj.rotation, + "name": obj.name, + "class": obj.class_, + } + + if obj.properties: + common["properties"] = serialize_properties(obj.properties) + + return common + + def _parse_ellipse(raw_object: RawObject) -> Ellipse: """Parse the raw object into an Ellipse. @@ -118,6 +138,12 @@ def _parse_ellipse(raw_object: RawObject) -> Ellipse: return Ellipse(**_parse_common(raw_object).__dict__) +def _serialize_ellipse(obj: Ellipse) -> RawObject: + common = _serialize_common(obj) + common["ellipse"] = True + return common + + def _parse_rectangle(raw_object: RawObject) -> Rectangle: """Parse the raw object into a Rectangle. @@ -130,6 +156,10 @@ def _parse_rectangle(raw_object: RawObject) -> Rectangle: return Rectangle(**_parse_common(raw_object).__dict__) +def _serialize_rectangle(obj: Rectangle) -> RawObject: + return _serialize_common(obj) + + def _parse_point(raw_object: RawObject) -> Point: """Parse the raw object into a Point. @@ -142,6 +172,12 @@ def _parse_point(raw_object: RawObject) -> Point: return Point(**_parse_common(raw_object).__dict__) +def _serialize_point(obj: Point) -> RawObject: + common = _serialize_common(obj) + common["point"] = True + return common + + def _parse_polygon(raw_object: RawObject) -> Polygon: """Parse the raw object into a Polygon. @@ -158,6 +194,17 @@ def _parse_polygon(raw_object: RawObject) -> Polygon: return Polygon(points=polygon, **_parse_common(raw_object).__dict__) +def _serialize_polygon(obj: Polygon) -> RawObject: + common = _serialize_common(obj) + + points = [] + for point in obj.points: + points.append({"x": point.x, "y": point.y}) + common["polygon"] = points + + return common + + def _parse_polyline(raw_object: RawObject) -> Polyline: """Parse the raw object into a Polyline. @@ -174,6 +221,17 @@ def _parse_polyline(raw_object: RawObject) -> Polyline: return Polyline(points=polyline, **_parse_common(raw_object).__dict__) +def _serialize_polyline(obj: Polyline) -> RawObject: + common = _serialize_common(obj) + + points = [] + for point in obj.points: + points.append({"x": point.x, "y": point.y}) + common["polyline"] = points + + return common + + def _parse_tile( raw_object: RawObject, new_tileset: Optional[Dict[str, Any]] = None, @@ -197,6 +255,12 @@ def _parse_tile( ) +def _serialize_tile(obj: Tile) -> RawObject: + common = _serialize_common(obj) + common["gid"] = obj.gid + return common + + def _parse_text(raw_object: RawObject) -> Text: """Parse the raw object into Text. @@ -250,6 +314,47 @@ def _parse_text(raw_object: RawObject) -> Text: return text_object +def _serialize_text(obj: Text) -> RawObject: + common = _serialize_common(obj) + + text: RawText = {"text": obj.text} + if obj.color != Color(0, 0, 0, 255): + text["color"] = serialize_color(obj.color) + + if obj.font_family != "sans-serif": + text["fontfamily"] = obj.font_family + + if obj.font_size != 16: + text["pixelsize"] = obj.font_size + + if obj.bold: + text["bold"] = obj.bold + + if obj.italic: + text["italic"] = obj.italic + + if not obj.kerning: + text["kerning"] = obj.kerning + + if obj.strike_out: + text["strikeout"] = obj.strike_out + + if obj.underline: + text["underline"] = obj.underline + + if obj.horizontal_align != "left": + text["halign"] = obj.horizontal_align + + if obj.vertical_align != "top": + text["valign"] = obj.vertical_align + + if obj.wrap: + text["wrap"] = obj.wrap + + common["text"] = text + return common + + def _get_parser(raw_object: RawObject) -> Callable[[RawObject], TiledObject]: """Get the parser function for a given raw object. @@ -346,3 +451,24 @@ def parse( return _parse_tile(raw_object, new_tileset, new_tileset_path) return _get_parser(raw_object)(raw_object) + + +def serialize(obj: TiledObject) -> RawObject: + if isinstance(obj, Tile): + return _serialize_tile(obj) + elif isinstance(obj, Text): + return _serialize_text(obj) + elif isinstance(obj, Rectangle): + return _serialize_rectangle(obj) + elif isinstance(obj, Polyline): + return _serialize_polyline(obj) + elif isinstance(obj, Polygon): + return _serialize_polygon(obj) + elif isinstance(obj, Point): + return _serialize_point(obj) + elif isinstance(obj, Ellipse): + return _serialize_ellipse(obj) + + raise AttributeError( + f"Unknown Object Type of {type(obj)} passed to tiled_object.serialize" + ) diff --git a/pytiled_parser/parsers/json/tileset.py b/pytiled_parser/parsers/json/tileset.py index cbbc51de..4a2c970c 100644 --- a/pytiled_parser/parsers/json/tileset.py +++ b/pytiled_parser/parsers/json/tileset.py @@ -6,12 +6,15 @@ from pytiled_parser.common_types import OrderedPair from pytiled_parser.parsers.json.layer import RawLayer from pytiled_parser.parsers.json.layer import parse as parse_layer +from pytiled_parser.parsers.json.layer import serialize as serialize_layer from pytiled_parser.parsers.json.properties import RawProperty from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.properties import serialize as serialize_properties from pytiled_parser.parsers.json.wang_set import RawWangSet from pytiled_parser.parsers.json.wang_set import parse as parse_wangset +from pytiled_parser.parsers.json.wang_set import serialize as serialize_wangset from pytiled_parser.tileset import Frame, Grid, Tile, Tileset, Transformations -from pytiled_parser.util import parse_color +from pytiled_parser.util import parse_color, serialize_color RawFrame = TypedDict("RawFrame", {"duration": int, "tileid": int}) RawFrame.__doc__ = """ @@ -113,6 +116,10 @@ def _parse_frame(raw_frame: RawFrame) -> Frame: return Frame(duration=raw_frame["duration"], tile_id=raw_frame["tileid"]) +def _serialize_frame(frame: Frame) -> RawFrame: + return {"duration": frame.duration, "tileid": frame.tile_id} + + def _parse_tile_offset(raw_tile_offset: RawTileOffset) -> OrderedPair: """Parse the raw_tile_offset to an OrderedPair. @@ -126,6 +133,10 @@ def _parse_tile_offset(raw_tile_offset: RawTileOffset) -> OrderedPair: return OrderedPair(raw_tile_offset["x"], raw_tile_offset["y"]) +def _serialize_tile_offset(tile_offset: OrderedPair) -> RawTileOffset: + return {"x": int(tile_offset.x), "y": int(tile_offset.y)} + + def _parse_transformations(raw_transformations: RawTransformations) -> Transformations: """Parse the raw_transformations to a Transformations object. @@ -144,6 +155,15 @@ def _parse_transformations(raw_transformations: RawTransformations) -> Transform ) +def _serialize_transformations(transformations: Transformations) -> RawTransformations: + return { + "hflip": transformations.hflip, + "vflip": transformations.vflip, + "rotate": transformations.rotate, + "preferuntransformed": transformations.prefer_untransformed, + } + + def _parse_grid(raw_grid: RawGrid) -> Grid: """Parse the raw_grid to a Grid object. @@ -161,6 +181,10 @@ def _parse_grid(raw_grid: RawGrid) -> Grid: ) +def _serialize_grid(grid: Grid) -> RawGrid: + return {"orientation": grid.orientation, "width": grid.width, "height": grid.height} + + def _parse_tile(raw_tile: RawTile, external_path: Optional[Path] = None) -> Tile: """Parse the raw_tile to a Tile object. @@ -228,6 +252,48 @@ def _parse_tile(raw_tile: RawTile, external_path: Optional[Path] = None) -> Tile return tile +def _serialize_tile(tile: Tile) -> RawTile: + raw_tile: RawTile = {"id": tile.id} + + if tile.animation: + raw_tile["animation"] = [] + for frame in tile.animation: + raw_tile["animation"].append(_serialize_frame(frame)) + + if tile.objects: + raw_tile["objectgroup"] = serialize_layer(tile.objects) + + if tile.properties: + raw_tile["properties"] = serialize_properties(tile.properties) + + if tile.image: + # TODO: This is an absolute path, need to figure out relative paths for serialization + raw_tile["image"] = str(tile.image) + + if tile.image_width: + raw_tile["imagewidth"] = tile.image_width + + if tile.image_height: + raw_tile["imageheight"] = tile.image_height + + if tile.class_: + raw_tile["class"] = tile.class_ + + if tile.x: + raw_tile["x"] = tile.x + + if tile.y: + raw_tile["y"] = tile.y + + if tile.width: + raw_tile["width"] = tile.width + + if tile.height: + raw_tile["height"] = tile.height + + return raw_tile + + def parse( raw_tileset: RawTileSet, firstgid: int, @@ -337,3 +403,77 @@ def parse( tileset.fill_mode = raw_tileset["fillmode"] return tileset + + +def serialize(tileset: Tileset) -> RawTileSet: + raw_tileset: RawTileSet = { + "name": tileset.name, + "tilecount": tileset.tile_count, + "tilewidth": tileset.tile_width, + "tileheight": tileset.tile_height, + "spacing": tileset.spacing, + "margin": tileset.margin, + "columns": tileset.columns, + } + + if tileset.version is not None: + raw_tileset["version"] = tileset.version + + if tileset.tiled_version is not None: + raw_tileset["tiledversion"] = tileset.tiled_version + + if tileset.image is not None: + # TODO: This is an absolute path, need to handle relative paths for serialization + raw_tileset["image"] = str(tileset.image) + + if tileset.image_width is not None: + raw_tileset["imagewidth"] = tileset.image_width + + if tileset.image_height is not None: + raw_tileset["imageheight"] = tileset.image_height + + if tileset.alignment is not None: + raw_tileset["objectalignment"] = tileset.alignment + + if tileset.background_color is not None: + raw_tileset["backgroundcolor"] = serialize_color(tileset.background_color) + + if tileset.tile_offset is not None: + raw_tileset["tileoffset"] = _serialize_tile_offset(tileset.tile_offset) + + if tileset.transparent_color is not None: + raw_tileset["transparentcolor"] = serialize_color(tileset.transparent_color) + + if tileset.grid is not None: + raw_tileset["grid"] = _serialize_grid(tileset.grid) + + if tileset.properties is not None: + raw_tileset["properties"] = serialize_properties(tileset.properties) + + if tileset.tiles is not None: + tiles = [] + for tile_id, tile in tileset.tiles.items(): + tiles.append(_serialize_tile(tile)) + raw_tileset["tiles"] = tiles + + if tileset.transformations is not None: + raw_tileset["transformations"] = _serialize_transformations( + tileset.transformations + ) + + if tileset.class_ is not None: + raw_tileset["class"] = tileset.class_ + + if tileset.tile_render_size != "tile": + raw_tileset["tilerendersize"] = tileset.tile_render_size + + if tileset.fill_mode != "stretch": + raw_tileset["fillmode"] = tileset.fill_mode + + if tileset.wang_sets is not None: + raw_wang_sets = [] + for wang_set in tileset.wang_sets: + raw_wang_sets.append(serialize_wangset(wang_set)) + raw_tileset["wangsets"] = raw_wang_sets + + return raw_tileset diff --git a/pytiled_parser/parsers/json/wang_set.py b/pytiled_parser/parsers/json/wang_set.py index f44c4b2c..bc90af7e 100644 --- a/pytiled_parser/parsers/json/wang_set.py +++ b/pytiled_parser/parsers/json/wang_set.py @@ -4,7 +4,8 @@ from pytiled_parser.parsers.json.properties import RawProperty from pytiled_parser.parsers.json.properties import parse as parse_properties -from pytiled_parser.util import parse_color +from pytiled_parser.parsers.json.properties import serialize as serialize_properties +from pytiled_parser.util import parse_color, serialize_color from pytiled_parser.wang_set import WangColor, WangSet, WangTile RawWangTile = TypedDict( @@ -66,6 +67,10 @@ def _parse_wang_tile(raw_wang_tile: RawWangTile) -> WangTile: return WangTile(tile_id=raw_wang_tile["tileid"], wang_id=raw_wang_tile["wangid"]) +def _serialize_wang_tile(wang_tile: WangTile) -> RawWangTile: + return {"tileid": wang_tile.tile_id, "wangid": wang_tile.wang_id} + + def _parse_wang_color(raw_wang_color: RawWangColor) -> WangColor: """Parse the raw wang color into a pytiled_parser type @@ -88,6 +93,20 @@ def _parse_wang_color(raw_wang_color: RawWangColor) -> WangColor: return wang_color +def _serialize_wang_color(wang_color: WangColor) -> RawWangColor: + raw_wang_color: RawWangColor = { + "name": wang_color.name, + "color": serialize_color(wang_color.color), + "tile": wang_color.tile, + "probability": wang_color.probability, + } + + if wang_color.properties is not None: + raw_wang_color["properties"] = serialize_properties(wang_color.properties) + + return raw_wang_color + + def parse(raw_wangset: RawWangSet) -> WangSet: """Parse the raw wangset into a pytiled_parser type @@ -118,3 +137,26 @@ def parse(raw_wangset: RawWangSet) -> WangSet: wangset.properties = parse_properties(raw_wangset["properties"]) return wangset + + +def serialize(wangset: WangSet) -> RawWangSet: + colors = [] + for wang_color in wangset.wang_colors: + colors.append(_serialize_wang_color(wang_color)) + + tiles = [] + for tile_id, wang_tile in wangset.wang_tiles.items(): + tiles.append(_serialize_wang_tile(wang_tile)) + + raw_wangset: RawWangSet = { + "name": wangset.name, + "tile": wangset.tile, + "type": wangset.wang_type, + "colors": colors, + "wangtiles": tiles, + } + + if wangset.properties is not None: + raw_wangset["properties"] = serialize_properties(wangset.properties) + + return raw_wangset diff --git a/pytiled_parser/parsers/tmx/layer.py b/pytiled_parser/parsers/tmx/layer.py index ffb8e41e..7a6da3c4 100644 --- a/pytiled_parser/parsers/tmx/layer.py +++ b/pytiled_parser/parsers/tmx/layer.py @@ -223,20 +223,18 @@ def _parse_tile_layer(raw_layer: etree.Element) -> TileLayer: data_element = raw_layer.find("data") if data_element is not None: - encoding = None if data_element.attrib.get("encoding") is not None: - encoding = data_element.attrib["encoding"] + tile_layer.encoding = data_element.attrib["encoding"] - compression = "" if data_element.attrib.get("compression") is not None: - compression = data_element.attrib["compression"] + tile_layer.compression = data_element.attrib["compression"] raw_chunks = data_element.findall("chunk") if not raw_chunks: - if encoding and encoding != "csv": + if tile_layer.encoding != "csv": tile_layer.data = _decode_tile_layer_data( data=data_element.text, # type: ignore - compression=compression, + compression=tile_layer.compression, layer_width=int(raw_layer.attrib["width"]), ) else: @@ -250,8 +248,8 @@ def _parse_tile_layer(raw_layer: etree.Element) -> TileLayer: chunks.append( _parse_chunk( raw_chunk, - encoding, - compression, + tile_layer.encoding, + tile_layer.compression, ) ) diff --git a/pytiled_parser/serializers/__init__.py b/pytiled_parser/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytiled_parser/serializers/json/__init__.py b/pytiled_parser/serializers/json/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytiled_parser/util.py b/pytiled_parser/util.py index 8ef4e257..fa997b7a 100644 --- a/pytiled_parser/util.py +++ b/pytiled_parser/util.py @@ -37,6 +37,22 @@ def parse_color(color: str) -> Color: raise ValueError("Improperly formatted color passed to parse_color") +def serialize_color(color: Color) -> str: + """Converts pytiled-parser's Color into Tiled's hex string. + + pytiled-parser's color is an RGBA tuple, Tiled's hex string is #AARRGGBB + + Args: + color: The pytiled-parser color to serialize + + Returns: + str: The serialized color string + """ + return "#{:02x}{:02x}{:02x}{:02x}".format( + color.alpha, color.red, color.green, color.blue + ) + + def check_format(file_path: Path) -> str: with open(file_path) as file: line = file.readline().rstrip().strip() diff --git a/tests/test_data/layer_tests/all_layer_types/expected.py b/tests/test_data/layer_tests/all_layer_types/expected.py index 6485438c..d4a41ca6 100644 --- a/tests/test_data/layer_tests/all_layer_types/expected.py +++ b/tests/test_data/layer_tests/all_layer_types/expected.py @@ -15,6 +15,8 @@ "test": "test property", }, tint_color=common_types.Color(170, 255, 255, 255), + encoding="csv", + compression="", data=[ [ 1, diff --git a/tests/test_data/layer_tests/b64/expected.py b/tests/test_data/layer_tests/b64/expected.py index c55d67d2..05035343 100644 --- a/tests/test_data/layer_tests/b64/expected.py +++ b/tests/test_data/layer_tests/b64/expected.py @@ -9,6 +9,8 @@ visible=True, id=1, size=common_types.Size(8, 6), + encoding="base64", + compression="", data=[ [ 1, diff --git a/tests/test_data/layer_tests/b64_gzip/expected.py b/tests/test_data/layer_tests/b64_gzip/expected.py index c55d67d2..d87c2924 100644 --- a/tests/test_data/layer_tests/b64_gzip/expected.py +++ b/tests/test_data/layer_tests/b64_gzip/expected.py @@ -9,6 +9,8 @@ visible=True, id=1, size=common_types.Size(8, 6), + encoding="base64", + compression="gzip", data=[ [ 1, diff --git a/tests/test_data/layer_tests/b64_zlib/expected.py b/tests/test_data/layer_tests/b64_zlib/expected.py index c55d67d2..57192ccc 100644 --- a/tests/test_data/layer_tests/b64_zlib/expected.py +++ b/tests/test_data/layer_tests/b64_zlib/expected.py @@ -9,6 +9,8 @@ visible=True, id=1, size=common_types.Size(8, 6), + encoding="base64", + compression="zlib", data=[ [ 1, diff --git a/tests/test_data/layer_tests/infinite_map_b64/expected.py b/tests/test_data/layer_tests/infinite_map_b64/expected.py index a907a2d5..e0586f12 100644 --- a/tests/test_data/layer_tests/infinite_map_b64/expected.py +++ b/tests/test_data/layer_tests/infinite_map_b64/expected.py @@ -13,6 +13,8 @@ properties={ "test": "test property", }, + encoding="base64", + compression="", chunks=[ layer.Chunk( coordinates=common_types.OrderedPair(0, 0), diff --git a/tests/test_data/map_tests/external_tileset_dif_dir/expected.py b/tests/test_data/map_tests/external_tileset_dif_dir/expected.py index c90b09e6..2da11467 100644 --- a/tests/test_data/map_tests/external_tileset_dif_dir/expected.py +++ b/tests/test_data/map_tests/external_tileset_dif_dir/expected.py @@ -21,6 +21,8 @@ visible=True, id=2, size=common_types.Size(8, 6), + encoding="base64", + compression="zlib", data=[ [4, 3, 2, 1, 0, 0, 0, 0], [ diff --git a/tests/test_layer.py b/tests/test_layer.py index 727fca45..0192c12d 100644 --- a/tests/test_layer.py +++ b/tests/test_layer.py @@ -9,6 +9,7 @@ from pytiled_parser.common_types import OrderedPair, Size from pytiled_parser.parsers.json.layer import parse as parse_json +from pytiled_parser.parsers.json.layer import serialize as serialize_json from pytiled_parser.parsers.tmx.layer import parse as parse_tmx TESTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) @@ -99,6 +100,27 @@ def test_layer_integration(parser_type, layer_test): assert layers == expected.EXPECTED +@pytest.mark.parametrize("layer_test", ALL_LAYER_TESTS) +def test_layer_serialization(layer_test): + spec = importlib.util.spec_from_file_location( + "expected", layer_test / "expected.py" + ) + expected = importlib.util.module_from_spec(spec) + spec.loader.exec_module(expected) + + raw_layers = [serialize_json(layer) for layer in expected.EXPECTED] + parsed = [] + for raw_layer in raw_layers: + parsed.append(parse_json(raw_layer)) + + for layer in parsed: + fix_layer(layer) + + for layer in expected.EXPECTED: + fix_layer(layer) + + assert parsed == expected.EXPECTED + @pytest.mark.parametrize("parser_type", ["json", "tmx"]) def test_zstd_not_installed(parser_type): if parser_type == "json": diff --git a/tests/test_tiled_object_json.py b/tests/test_tiled_object_json.py index 90daf31b..ca25e4a5 100644 --- a/tests/test_tiled_object_json.py +++ b/tests/test_tiled_object_json.py @@ -6,7 +6,7 @@ import pytest from pytiled_parser import common_types -from pytiled_parser.parsers.json.tiled_object import parse +from pytiled_parser.parsers.json.tiled_object import parse, serialize from pytiled_parser.tiled_object import ( Ellipse, Point, @@ -1115,8 +1115,14 @@ def test_parse_layer(raw_object_json, expected): assert result == expected -def test_parse_no_parent_dir(): +@pytest.mark.parametrize("raw_object_json,expected", OBJECTS) +def test_serialize_layer(raw_object_json, expected): + raw = serialize(expected) + parsed = parse(raw) + assert parsed == expected + +def test_parse_no_parent_dir(): raw_object = """ { "id":1, @@ -1128,4 +1134,4 @@ def test_parse_no_parent_dir(): json_object = json.loads(raw_object) with pytest.raises(RuntimeError): - parse(json_object) + parse(json_object) \ No newline at end of file diff --git a/tests/test_tileset.py b/tests/test_tileset.py index 1216f1a8..bc82f562 100644 --- a/tests/test_tileset.py +++ b/tests/test_tileset.py @@ -10,6 +10,7 @@ from pytiled_parser import parse_tileset from pytiled_parser.common_types import OrderedPair, Size from pytiled_parser.parsers.json.tileset import parse as parse_json +from pytiled_parser.parsers.json.tileset import serialize as serialize_json from pytiled_parser.parsers.tmx.tileset import parse as parse_tmx TESTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) @@ -78,3 +79,20 @@ def test_tilesets_integration(parser_type, tileset_dir): fix_tileset(expected.EXPECTED) assert tileset_ == expected.EXPECTED + +@pytest.mark.parametrize("tileset_dir", ALL_TILESET_DIRS) +def test_tilesets_serialization(tileset_dir): + spec = importlib.util.spec_from_file_location( + "expected", tileset_dir / "expected.py" + ) + expected = importlib.util.module_from_spec(spec) + spec.loader.exec_module(expected) + + raw_tileset = serialize_json(expected.EXPECTED) + # Use a firstgid of 1, since these aren't actually in a map + parsed = parse_json(raw_tileset, 1) + + fix_tileset(parsed) + fix_tileset(expected.EXPECTED) + + assert parsed == expected.EXPECTED \ No newline at end of file