Bläddra i källkod

A round-trip of parsing and generating XML now gives identical result (in drawio) for the edgeLabel.drawio file

Joeri Exelmans 2 år sedan
förälder
incheckning
cb15bbfcfe
5 ändrade filer med 100 tillägg och 47 borttagningar
  1. 9 9
      drawio2py/abstract_syntax.py
  2. 24 24
      drawio2py/generator.py
  3. 22 7
      drawio2py/parser.py
  4. 4 4
      test/data/edgeLabel.drawio
  5. 41 3
      test/run_tests.py

+ 9 - 9
drawio2py/abstract_syntax.py

@@ -29,25 +29,25 @@ class Point(Element):
 	y: Optional[Decimal]
 
 @dataclass(eq=False)
-class VertexGeometry(Element):
-	x: Optional[Decimal]
-	y: Optional[Decimal]
-	width: Optional[Decimal]
-	height: Optional[Decimal]
-	offset: Optional[Point] # Relative offset of the Vertex' label
+class Geometry(Element):
+	x: Optional[Decimal] = None
+	y: Optional[Decimal] = None
+	width: Optional[Decimal] = None
+	height: Optional[Decimal] = None
+	offset: Optional[Point] = None # Relative offset of the Cell's label
+	relative: bool = False # Not sure what this means. Seems to be always 'true' for edges. Sometimes true for vertices.
 
 @dataclass(eq=False)
 class Vertex(Cell):
-	geometry: VertexGeometry
+	geometry: Geometry
 	outgoing: List['Edge']
 	incoming: List['Edge']
 
 @dataclass(eq=False)
-class EdgeGeometry(Element):
+class EdgeGeometry(Geometry):
 	points: List[Point] = field(default_factory=list)
 	source_point: Optional[Point] = None
 	target_point: Optional[Point] = None
-	offset: Optional[Point] = None # Relative offset of the Edge's label
 
 @dataclass(eq=False)
 class Edge(Cell):

+ 24 - 24
drawio2py/generator.py

@@ -1,16 +1,18 @@
 """
-Generates a Draw.IO file from the in-memory specification.
+Functions for converting in-memory drawio data to XML.
 """
 
 import xml.etree.ElementTree as ET
 from drawio2py.abstract_syntax import *
 
 
+# generates XML tree and serializes it to a file (or any writeable object) 
 def generate(drawio: DrawIOFile, file_object):
 	graph = generate_mxfile(drawio)
 	et = ET.ElementTree(graph)
 	et.write(file_object)
 
+# generates XML tree for a drawio "file" (root element: <mxfile>)
 def generate_mxfile(drawio: DrawIOFile) -> ET.Element:
 	graph = ET.Element("mxfile", {
 		"compressed": "false", # always write uncompressed files
@@ -23,7 +25,7 @@ def generate_mxfile(drawio: DrawIOFile) -> ET.Element:
 		graph.append(dia)
 	return graph
 
-
+# generates XML tree for a drawio "page" (root element: <diagram>)
 def generate_diagram(page: Page) -> ET.Element:
 	dia = ET.Element("diagram", {
 		"id": str(page.id),
@@ -81,32 +83,30 @@ def generate_diagram(page: Page) -> ET.Element:
 			})
 			return ET.SubElement(parent_xml, "mxPoint", attrs)
 
-		# Geometry
-		if isinstance(cell, Vertex):
-			g = ET.SubElement(c, "mxGeometry", optional_attributes({
-				"x": cell.geometry.x,
-				"y": cell.geometry.y,
-				"width": cell.geometry.width,
-				"height": cell.geometry.height,
+		def write_geometry(parent_xml, geometry):
+			return ET.SubElement(parent_xml, "mxGeometry", optional_attributes({
+				"x": geometry.x,
+				"y": geometry.y,
+				"width": geometry.width,
+				"height": geometry.height,
+				"relative": "1" if geometry.relative else None,
 				"as": "geometry",
 			}))
+
+		# Geometry
+		if isinstance(cell, Vertex) or isinstance(cell, Edge):
+			g = write_geometry(c, cell.geometry)
 			if cell.geometry.offset != None:
 				write_point(g, cell.geometry.offset, "offset")
-		elif isinstance(cell, Edge):
-			g = ET.SubElement(c, "mxGeometry", {
-				"relative": "1",
-				"as": "geometry",
-			})
-			if len(cell.geometry.points) > 0:
-				a = ET.SubElement(g, "Array", {"as": "points"})
-				for p in cell.geometry.points:
-					write_point(a, p)
-			if cell.geometry.source_point is not None:
-				write_point(g, cell.geometry.source_point, "sourcePoint")
-			if cell.geometry.target_point is not None:
-				write_point(g, cell.geometry.target_point, "targetPoint")
-			if cell.geometry.offset != None:
-				write_point(g, cell.geometry.offset, "offset")
+			if isinstance(cell, Edge):
+				if len(cell.geometry.points) > 0:
+					a = ET.SubElement(g, "Array", {"as": "points"})
+					for p in cell.geometry.points:
+						write_point(a, p)
+				if cell.geometry.source_point is not None:
+					write_point(g, cell.geometry.source_point, "sourcePoint")
+				if cell.geometry.target_point is not None:
+					write_point(g, cell.geometry.target_point, "targetPoint")
 
 		for child in cell.children:
 			write_cell(child)

+ 22 - 7
drawio2py/parser.py

@@ -54,7 +54,15 @@ class Parser:
 		target = None if target_xml == None else Parser.parse_point(target_xml)
 		offset = Parser.parse_offset_if_exists(groot)
 
-		return EdgeGeometry(pts, source, target, offset=offset)
+		return EdgeGeometry(
+			groot.get("x", None), groot.get("y", None),
+			groot.get("width", None), groot.get("height", None),
+			relative=groot.get("relative", "0")=="1",
+			offset=offset,
+			points=pts,
+			source_point=source,
+			target_point=target,
+		)
 
 	@staticmethod
 	def parse_offset_if_exists(geometry_xml):
@@ -70,9 +78,11 @@ class Parser:
 	def parse_cell_geometry(groot):
 		if groot is None: return None
 		offset = Parser.parse_offset_if_exists(groot)
-		return VertexGeometry(groot.get("x", None), groot.get("y", None),
-		                    groot.get("width", None), groot.get("height", None),
-		                    offset=offset)
+		return Geometry(
+			groot.get("x", None), groot.get("y", None),
+			groot.get("width", None), groot.get("height", None),
+			relative=groot.get("relative", "0")=="1",
+			offset=offset)
 
 	@staticmethod
 	def parse_object(oroot):
@@ -195,11 +205,16 @@ class Parser:
 		"""
 		tree = ET.parse(filename)
 		root = tree.getroot()
+		return Parser.parse_xml_root(root, filename)
+
+	# filename: not important, only for traceability purposes.
+	@staticmethod
+	def parse_xml_root(xml_root, filename="<NO FILENAME>"):
 		dfile = DrawIOFile(
 			filename=filename,
-			compressed=root.attrib.get("compressed", "true") == "true",
-			version=root.attrib.get("version"))
-		pages = root.findall(".//diagram")
+			compressed=xml_root.attrib.get("compressed", "true") == "true",
+			version=xml_root.attrib.get("version"))
+		pages = xml_root.findall(".//diagram")
 
 		for px, page in enumerate(pages):
 			dfile.pages.append(Parser.parse_page(page))

+ 4 - 4
test/data/edgeLabel.drawio

@@ -1,12 +1,12 @@
-<mxfile host="Electron" modified="2023-06-20T15:17:44.069Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.2.8 Chrome/114.0.5735.45 Electron/25.0.1 Safari/537.36" etag="uWCH2RuiU4VNz75uNGWD" version="21.2.8" type="device">
+<mxfile host="Electron" modified="2023-06-21T07:51:45.394Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.2.8 Chrome/114.0.5735.45 Electron/25.0.1 Safari/537.36" etag="ENjM_GS8G5Qs-gwF3YYm" version="21.2.8" type="device">
   <diagram name="Page-1" id="c7YBU_xrP0pCo13Y-Qku">
-    <mxGraphModel dx="1531" dy="1700" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
+    <mxGraphModel dx="434" dy="1957" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
       <root>
         <mxCell id="0" />
         <mxCell id="1" parent="0" />
         <mxCell id="zC1wFdxtIY5PVFzHPPmW-1" value="label" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1">
-          <mxGeometry x="-0.3081" y="-129" width="50" height="50" relative="1" as="geometry">
-            <mxPoint as="sourcePoint" />
+          <mxGeometry x="-0.1172" y="-120" width="50" height="50" relative="1" as="geometry">
+            <mxPoint x="40" as="sourcePoint" />
             <mxPoint x="220" y="160" as="targetPoint" />
             <mxPoint as="offset" />
           </mxGeometry>

+ 41 - 3
test/run_tests.py

@@ -4,6 +4,7 @@ import io
 import pprint
 import tempfile
 import unittest
+import xml.etree.ElementTree as ET
 
 from drawio2py import parser, abstract_syntax, generator, shapelib, util
 
@@ -13,6 +14,10 @@ class DummyOutput:
     def write(self, text: str):
         pass
 
+# The bare minimum that we can consider 'a test':
+# We parse XML, write it out again, then parse it again and write it out again.
+# Finally, we check if both serializations (the first and second one) are bitwise equal (they should).
+# To verify if the generated drawio file is really the "same" as the original, we currently manually open the file in drawio.
 def run_test(filename):
     # Parse (1st time):
     asyntax = parser.Parser.parse(os.path.join(DATADIR,filename))
@@ -35,6 +40,39 @@ def run_test(filename):
         # print("csyntax2:", csyntax2.getvalue())
         raise Exception("Files differ after round-trip!")
 
+# Compares two XML trees.
+# From: https://stackoverflow.com/a/24349916
+def elements_equal(e1, e2, depth=0):
+    print("  "*depth+e1.tag)
+    if e1.tag != e2.tag:
+        print("tags differ")
+        return False
+    if e1.tail != e2.tail:
+        print("tail differs")
+        return False
+    if e1.attrib != e2.attrib:
+        print("attributes differ")
+        pprint.pprint(e1.attrib)
+        pprint.pprint(e2.attrib)
+        return False
+    if len(e1) != len(e2):
+        print("number of children differs")
+        return False
+    return all(elements_equal(c1, c2, depth+1) for c1, c2 in zip(e1, e2))
+
+# Currently unused.
+# Our generated XML always differs from the parsed XML.
+# That's because we skip certain attributes.
+def assert_roundtrip_equal(filename):
+    expected_xml = ET.parse(os.path.join(DATADIR,filename)).getroot()
+
+    asyntax = parser.Parser.parse_xml_root(expected_xml)
+    actual_xml = generator.generate_mxfile(asyntax)
+
+    if not elements_equal(expected_xml, actual_xml):
+        raise Exception("Generated XML tree differs from parsed XML tree.")
+
+
 def parse_shapelib(filename):
     return shapelib.parse_library(os.path.join(DATADIR,filename))
 
@@ -51,12 +89,12 @@ class Tests(unittest.TestCase):
 
     def test_label_offset(self):
         run_test("labelOffset.drawio")
-        # asyntax = parser.Parser.parse(os.path.join(DATADIR, "labelOffset.drawio"))
-        # with open(os.path.join(DATADIR, "labelOffset-1.drawio"), 'wb') as f:
-        #     generator.generate(asyntax, f)
 
     def test_edge_label(self):
         run_test("edgeLabel.drawio")
+        # asyntax = parser.Parser.parse(os.path.join(DATADIR, "edgeLabel.drawio"))
+        # with open(os.path.join(DATADIR, "edgeLabel-1.drawio"), 'wb') as f:
+        #     generator.generate(asyntax, f)
 
     def test_shapelib(self):
         common_lib = parse_shapelib("shapelibs/common.xml")