From 3836958593f2d8f4f168713f15a8dc658e8962fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Olaya?= Date: Wed, 8 Dec 2021 22:32:05 +0100 Subject: [PATCH] Several changes, mostly for lyrx->geostyler conversion (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Install submodules in setup.py * Create worflow to notify camptocamp/lyrx2sld on updates * Add custom event type * added support for HSV colors in arcgis format * added library version * some changes to style2style * add version to SLD output * added mapping for esri fonts markers * Delete notify.yaml * Add GitHub Action to notify lyrx2sld on updates * Update notify.yaml * Update notify.yaml * fix for hatch fill from arcgis format * Update mapping of ESRI symbols * fixed case of no color value in arcgis format * handle case of possible missing fieldValues in arcgis format * fixed case of missing halo symbol in arcgis format * correctly handle case of CIMUniqueValueRenderer with no groups * support for CIMPictureMarker * handle icons on style2style.py This changes the return from togeostyler and fromgestyler methods for all formts * handle case of missing label property in arcgis format * show error on empty geostyler result * added support for CIMClassBreaksRenderer * prevent exception if rgb values are float in arcgis format * round dash definition values when float values are used in arcgis format * fixed issue with when arcgis style has no else rule * fied cmyk2rgb conversion and usage of scale dependency for labels * minor fixes for lyrx2geostyler conversion * invert order of symbolizers when converting from lyrx into geostyler * Reverse symbol order * Typo * reverted support for z-ordering * removed unused method * LYRX: don't convert outline of polygons to line symbolizer * fixed dashed outlines and hatched fills in lyrx2geostyler conversion * added support for concatenated strings from lyrx format * removed unused code * fixed concatenation of strings in labels in arcgis format * added support for rotation in lyrx to geostyler conversion Also, this commit fixes the handling of min/max scale denominators * Fix rotation sign * Add support for symbol transparency (#10) * support whereClause in conversion of labels from lyrx (#11) * Improve symbol support (#13) * Use symbol fill color, refine opacity support * Use stroke width, default to 0 * Default symbol fill color to white * Add support for null value comparison (#14) GEO-4833 * do not add polygon fill if not present in lyrx source * added support for CIMGrayColor in lyrx * added partial support for CIMVectorMarker * correctly add 'group' vendor option in sld output * fixed halos and class breaks in lyrx->geostyler conversion * removed workflows Co-authored-by: Cécile Vuilleumier --- bridgestyle/__init__.py | 1 + bridgestyle/arcgis/__init__.py | 7 +- bridgestyle/arcgis/expressions.py | 62 +++++ bridgestyle/arcgis/togeostyler.py | 381 ++++++++++++++++++++++-------- bridgestyle/geostyler/__init__.py | 4 +- bridgestyle/mapboxgl/__init__.py | 3 +- bridgestyle/mapserver/__init__.py | 2 +- bridgestyle/qgis/expressions.py | 2 - bridgestyle/sld/__init__.py | 3 +- bridgestyle/sld/fromgeostyler.py | 54 +---- bridgestyle/style2style.py | 32 ++- bridgestyle/version.py | 1 + setup.py | 3 +- 13 files changed, 396 insertions(+), 159 deletions(-) create mode 100644 bridgestyle/arcgis/expressions.py create mode 100644 bridgestyle/version.py diff --git a/bridgestyle/__init__.py b/bridgestyle/__init__.py index e69de29..7152555 100644 --- a/bridgestyle/__init__.py +++ b/bridgestyle/__init__.py @@ -0,0 +1 @@ +from .version import __version__ \ No newline at end of file diff --git a/bridgestyle/arcgis/__init__.py b/bridgestyle/arcgis/__init__.py index 0923ca2..e5f5a34 100644 --- a/bridgestyle/arcgis/__init__.py +++ b/bridgestyle/arcgis/__init__.py @@ -5,10 +5,9 @@ def toGeostyler(style, options=None): - geostyler, _, _ = togeostyler.convert(json.loads(style), options) - return geostyler + return togeostyler.convert(json.loads(style), options) def fromGeostyler(style, options=None): - arcgisjson, warnings = fromgeostyler.convert(style, options) - return arcgisjson + return fromgeostyler.convert(style, options) + diff --git a/bridgestyle/arcgis/expressions.py b/bridgestyle/arcgis/expressions.py new file mode 100644 index 0000000..e67c102 --- /dev/null +++ b/bridgestyle/arcgis/expressions.py @@ -0,0 +1,62 @@ +# For now, this is limited to compound labels using the python or VB syntax +def convertExpression(expression, tolowercase): + if tolowercase: + expression = expression.lower() + if "+" in expression or "&" in expression: + if "+" in expression: + tokens = expression.split("+")[::-1] + else: + tokens = expression.split("&")[::-1] + addends = [] + for token in tokens: + if "[" in token: + addends.append(["PropertyName", token.replace("[", "").replace("]", "").strip()]) + else: + addends.append(token.replace('"', '')) + allOps = addends[0] + for attr in addends[1:]: + allOps = ["Concatenate", attr, allOps] + expression = allOps + else: + expression = ["PropertyName", expression.replace("[", "").replace("]", "")] + return expression + + +def stringToParameter(s, tolowercase): + s = s.strip() + if "'" in s or '"' in s: + return s.strip("'\"") + else: + s = s.lower() if tolowercase else s + return ["PropertyName", s] + + +# For now, limited to = or IN statements +# There is no formal parsing, just a naive conversion +def convertWhereClause(clause, tolowercase): + if "=" in clause: + tokens = clause.split("=") + expression = ["PropertyIsEqualTo", + stringToParameter(tokens[0], tolowercase), + stringToParameter(tokens[1], tolowercase)] + return expression + elif " in " in clause.lower(): + clause = clause.replace(" IN ", " in ") + tokens = clause.split(" in ") + attribute = tokens[0] + values = tokens[1].strip("() ").split(",") + subexpressions = [] + for v in values: + subexpressions.append(["PropertyIsEqualTo", + stringToParameter(attribute, tolowercase), + stringToParameter(v, tolowercase)]) + expression = [] + if len(values) == 1: + return subexpressions[0] + else: + accum = ["Or", subexpressions[0], subexpressions[1]] + for subexpression in subexpressions[2:]: + accum = ["Or", accum, subexpression] + return accum + + return clause diff --git a/bridgestyle/arcgis/togeostyler.py b/bridgestyle/arcgis/togeostyler.py index 1e8ea2c..e8cd377 100644 --- a/bridgestyle/arcgis/togeostyler.py +++ b/bridgestyle/arcgis/togeostyler.py @@ -4,6 +4,8 @@ import tempfile import uuid +from .expressions import convertExpression, convertWhereClause + ESRI_SYMBOLS_FONT = "ESRI Default Marker" _usedIcons = [] @@ -30,13 +32,24 @@ def processLayer(layer, options=None): if renderer["type"] == "CIMSimpleRenderer": rules.append(processSimpleRenderer(renderer, options)) elif renderer["type"] == "CIMUniqueValueRenderer": - for group in renderer["groups"]: - rules.extend(processUniqueValueGroup(renderer["fields"], - group, options)) + if "groups" in renderer: + for group in renderer["groups"]: + rules.extend(processUniqueValueGroup(renderer["fields"], + group, options)) + else: + if "defaultSymbol" in renderer: + # this is really a simple renderer + rule = {"name": "", + "symbolizers": processSymbolReference(renderer["defaultSymbol"], options)} + rules.append(rule) + + elif (renderer["type"] == "CIMClassBreaksRenderer" + and renderer.get("classBreakType") == "GraduatedColor"): + rules.extend(processClassBreaksRenderer(renderer, options)) else: _warnings.append( "Unsupported renderer type: %s" % str(renderer)) - return + return geostyler if layer.get("labelVisibility", False): for labelClass in layer.get("labelClasses", []): @@ -51,15 +64,58 @@ def processLayer(layer, options=None): return geostyler +def processClassBreaksRenderer(renderer, options): + rules = [] + field = renderer["field"] + lastbound = None + for classbreak in renderer.get("breaks", []): + tolowercase = options.get("tolowercase", False) + symbolizers = processSymbolReference(classbreak["symbol"], options) + upperbound = classbreak.get("upperBound", 0) + if lastbound is not None: + filt = ["And", + ["PropertyIsGreaterThan", + [ + "PropertyName", + field.lower() if tolowercase else field + ], + lastbound + ], + ["PropertyIsLessThanOrEqualTo", + [ + "PropertyName", + field.lower() if tolowercase else field + ], + upperbound + ], + ] + else: + filt = ["PropertyIsLessThanOrEqualTo", + [ + "PropertyName", + field.lower() if tolowercase else field + ], + upperbound + ] + lastbound = upperbound + ruledef = {"name": classbreak.get("label", "classbreak"), + "symbolizers": symbolizers, + "filter": filt} + rules.append(ruledef) + + return rules + + def processLabelClass(labelClass, tolowercase=False): textSymbol = labelClass["textSymbol"]["symbol"] - expression = labelClass["expression"].replace("[", "").replace("]", "") + expression = convertExpression(labelClass["expression"], tolowercase) fontFamily = textSymbol.get('fontFamilyName', 'Arial') fontSize = textSymbol.get('height', 12) color = _extractFillColor(textSymbol["symbol"]['symbolLayers']) fontWeight = textSymbol.get('fontStyleName', 'Regular') - #minimumScale = labelParse['minimumScale'] or '' - + rotationProps = (labelClass.get("maplexLabelPlacementProperties", {}) + .get("rotationProperties", {})) + rotationField = rotationProps.get("rotationField") symbolizer = { "kind": "Text", "offset": [ @@ -70,22 +126,43 @@ def processLabelClass(labelClass, tolowercase=False): "rotate": 0.0, "color": color, "font": fontFamily, - "label": [ - "PropertyName", - expression.lower() if tolowercase else expression - ], + "label": expression, "size": fontSize } + if rotationField is not None: + symbolizer["rotate"] = ["Mul", + [ + "PropertyName", + rotationField.lower() if tolowercase else rotationField + ], + -1 + ] + else: + symbolizer["rotate"] = 0.0 haloSize = textSymbol.get("haloSize") - if haloSize: + if haloSize and "haloSymbol" in textSymbol: haloColor = _extractFillColor(textSymbol["haloSymbol"]['symbolLayers']) symbolizer.update({"haloColor": haloColor, "haloSize": haloSize, "haloOpacity": 1}) + rule = {"name": "", "symbolizers": [symbolizer]} + scaleDenominator = {} + minimumScale = labelClass.get("minimumScale") + if minimumScale is not None: + scaleDenominator = {"max": minimumScale} + maximumScale = labelClass.get("maximumScale") + if maximumScale is not None: + scaleDenominator = {"min": maximumScale} + if scaleDenominator: + rule["scaleDenominator"] = scaleDenominator + + if "whereClause" in labelClass: + rule["filter"] = convertWhereClause(labelClass["whereClause"], tolowercase) + return rule @@ -102,6 +179,13 @@ def _and(a, b): def _or(a, b): return ["Or", a, b] def _equal(name, val): + if val == "": + return ["PropertyIsNull", + [ + "PropertyName", + name.lower() if tolowercase else name + ] + ] return ["PropertyIsEqualTo", [ "PropertyName", @@ -111,23 +195,25 @@ def _equal(name, val): ] rules = [] for clazz in group["classes"]: - rule = {"name": clazz["label"]} + rule = {"name": clazz.get("label", "label")} values = clazz["values"] conditions = [] for v in values: - fieldValues = v["fieldValues"] - condition = _equal(fields[0], fieldValues[0]) - for fieldValue, fieldName in zip(fieldValues[1:], fields[1:]): - condition = _and(condition, _equal(fieldName, fieldValue)) - conditions.append(condition) - - ruleFilter = conditions[0] - for condition in conditions[1:]: - ruleFilter = _or(ruleFilter, condition) - - rule["filter"] = ruleFilter - rule["symbolizers"] = processSymbolReference(clazz["symbol"], options) - rules.append(rule) + if "fieldValues" in v: + fieldValues = v["fieldValues"] + condition = _equal(fields[0], fieldValues[0]) + for fieldValue, fieldName in zip(fieldValues[1:], fields[1:]): + condition = _and(condition, _equal(fieldName, fieldValue)) + conditions.append(condition) + if conditions: + ruleFilter = conditions[0] + for condition in conditions[1:]: + ruleFilter = _or(ruleFilter, condition) + + rule["filter"] = ruleFilter + rule["symbolizers"] = processSymbolReference(clazz["symbol"], options) + rules.append(rule) + return rules @@ -135,89 +221,132 @@ def processSymbolReference(symbolref, options): symbol = symbolref["symbol"] symbolizers = [] if "symbolLayers" in symbol: - for layer in symbol["symbolLayers"]: - symbolizer = processSymbolLayer(layer, options) - if layer["type"] in ["CIMVectorMarker", "CIMPictureFill", "CIMCharacterMarker"]: - if symbol["type"] == "CIMLineSymbol": - symbolizer = {"kind": "Line", - "opacity": 1.0, - "perpendicularOffset": 0.0, - "graphicStroke": [symbolizer], - "graphicStrokeInterval": symbolizer["size"] * 2, #TODO - "graphicStrokeOffset": 0.0, - "Z": 0} - elif symbol["type"] == "CIMPolygonSymbol": - symbolizer = {"kind": "Fill", - "opacity": 1.0, - "perpendicularOffset": 0.0, - "graphicFill": [symbolizer], - "graphicFillMarginX": symbolizer["size"] * 2, #TODO - "graphicFillMarginY": symbolizer["size"] * 2, - "Z": 0} - symbolizers.append(symbolizer) + for layer in symbol["symbolLayers"][::-1]: #drawing order for geostyler is inverse of rule order + symbolizer = processSymbolLayer(layer, symbol["type"], options) + if symbolizer is not None: + if layer["type"] in ["CIMVectorMarker", "CIMPictureFill", "CIMCharacterMarker"]: + if symbol["type"] == "CIMLineSymbol": + symbolizer = {"kind": "Line", + "opacity": 1.0, + "perpendicularOffset": 0.0, + "graphicStroke": [symbolizer], + "graphicStrokeInterval": symbolizer["size"] * 2, #TODO + "graphicStrokeOffset": 0.0, + "Z": 0} + elif symbol["type"] == "CIMPolygonSymbol": + symbolizer = {"kind": "Fill", + "opacity": 1.0, + "perpendicularOffset": 0.0, + "graphicFill": [symbolizer], + "graphicFillMarginX": symbolizer["size"] * 2, #TODO + "graphicFillMarginY": symbolizer["size"] * 2, + "Z": 0} + symbolizers.append(symbolizer) return symbolizers + def processEffect(effect): if effect["type"] == "CIMGeometricEffectDashes": - return {"dasharray": " ". join(str(v) for v in effect["dashTemplate"])} + return {"dasharray": " ".join(str(math.ceil(v)) for v in effect["dashTemplate"])} else: return {} + def _hatchMarkerForAngle(angle): quadrant = math.floor(((angle + 22.5) % 180) / 45.0) return [ - "shape://horline", - "shape://backslash", "shape://vertline", - "shape://slash" + "shape://slash", + "shape://horline", + "shape://backslash" ][quadrant] -def _esriFontToStandardSymbols(name): - # So far, we don't have a mapping of symbols, so we return a default one - _warnings.append( - f"Unsupported symbol from ESRI font ({name}) replaced by default marker") - return "circle" -def processSymbolLayer(layer, options): +def _esriFontToStandardSymbols(charindex): + mapping = {33: "circle", + 34: "square", + 35: "triangle", + 40: "circle", + 41: "square", + 42: "triangle", + 94: "star", + 95: "star", + 203: "cross", + 204: "cross"} + if charindex in mapping: + return mapping[charindex] + else: + _warnings.append( + f"Unsupported symbol from ESRI font (character index {charindex}) replaced by default marker") + return "circle" + + +def processSymbolLayer(layer, symboltype, options): replaceesri = options.get("replaceesri", False) if layer["type"] == "CIMSolidStroke": - stroke = { - "kind": "Line", - "color": processColor(layer["color"]), - "opacity": 1.0, - "width": layer["width"], - "perpendicularOffset": 0.0, - "cap": layer["capStyle"].lower(), - "join": layer["joinStyle"].lower(), - } + effects = {} if "effects" in layer: for effect in layer["effects"]: - stroke.update(processEffect(effect)) + effects.update(processEffect(effect)) + if symboltype == "CIMPolygonSymbol": + stroke = { + "kind": "Fill", + "outlineColor": processColor(layer.get("color")), + "outlineOpacity": 1.0, + "outlineWidth": layer["width"], + } + if "dasharray" in effects: + stroke["outlineDasharray"] = effects["dasharray"] + else: + stroke = { + "kind": "Line", + "color": processColor(layer.get("color")), + "opacity": 1.0, + "width": layer["width"], + "perpendicularOffset": 0.0, + "cap": layer["capStyle"].lower(), + "join": layer["joinStyle"].lower(), + } + if "dasharray" in effects: + stroke["asharray"] = effects["dasharray"] return stroke elif layer["type"] == "CIMSolidFill": - return { - "kind": "Fill", - "opacity": 1.0, - "color": processColor(layer["color"]), - "fillOpacity": 1.0 - } + color = layer.get("color") + if color is not None: + return { + "kind": "Fill", + "opacity": 1.0, + "color": processColor(color), + "fillOpacity": 1.0 + } elif layer["type"] == "CIMCharacterMarker": fontFamily = layer["fontFamilyName"] - hexcode = hex(layer["characterIndex"]) + charindex = layer["characterIndex"] + hexcode = hex(charindex) if fontFamily == ESRI_SYMBOLS_FONT and replaceesri: - name = _esriFontToStandardSymbols(hexcode) + name = _esriFontToStandardSymbols(charindex) else: name = "ttf://%s#%s" % (fontFamily, hexcode) rotate = layer.get("rotation", 0) try: - color = processColor(layer["symbol"]["symbolLayers"][0]["color"]) + symbolLayers = layer["symbol"]["symbolLayers"] + fillColor = _extractFillColor(symbolLayers) + fillOpacity = _extractFillOpacity(symbolLayers) + strokeOpacity = _extractStrokeOpacity(symbolLayers) + strokeColor, strokeWidth = _extractStroke(symbolLayers) except KeyError: - color = "#000000" + fillColor = "#000000" + fillOpacity = 1.0 + strokeOpacity = 0 + strokeWidth = 0.0 return { "opacity": 1.0, + "fillOpacity": fillOpacity, + "strokeOpacity": strokeOpacity, + "strokeWidth": strokeWidth, "rotate": rotate, "kind": "Mark", - "color": color, + "color": fillColor, "wellKnownName": name, "size": layer["size"], "Z": 0 @@ -225,22 +354,31 @@ def processSymbolLayer(layer, options): elif layer["type"] == "CIMVectorMarker": #TODO + #we do not take the shape, but just the colors and stroke width + markerGraphics = layer.get("markerGraphics",[]) + if markerGraphics: + sublayers = markerGraphics[0]["symbol"]["symbolLayers"] + fillColor = _extractFillColor(sublayers) + strokeColor, strokeWidth = _extractStroke(sublayers) + else: + fillColor = "#ff0000" + strokeColor = "#000000" return{ "opacity": 1.0, "rotate": 0.0, "kind": "Mark", - "color": "#ff0000", + "color": fillColor, "wellKnownName": "circle", "size": 10, - "strokeColor": "#000000", - "strokeWidth": 1, + "strokeColor": strokeColor, + "strokeWidth": strokeWidth, "strokeOpacity": 1.0, "fillOpacity": 1.0, "Z": 0 } elif layer["type"] == "CIMHatchFill": rotation = layer.get("rotation", 0) - separation = layer.get("separation", 3) + separation = layer.get("separation", 2) symbolLayers = layer["lineSymbol"]["symbolLayers"] color, width = _extractStroke(symbolLayers) @@ -252,7 +390,7 @@ def processSymbolLayer(layer, options): "kind": "Mark", "color": color, "wellKnownName": _hatchMarkerForAngle(rotation), - "size": separation, + "size": separation + width, "strokeColor": color, "strokeWidth": width, "rotate": 0 @@ -260,7 +398,7 @@ def processSymbolLayer(layer, options): ], "Z": 0 } - elif layer["type"] == "CIMPictureFill": + elif layer["type"] in ["CIMPictureFill", "CIMPictureMarker"]: url = layer["url"] if not os.path.exists(url): tokens = url.split(";") @@ -278,44 +416,71 @@ def processSymbolLayer(layer, options): url = iconFile rotate = layer.get("rotation", 0) - height = layer["height"] + size = layer.get("height", layer.get("size")) return { "opacity": 1.0, "rotate": 0.0, "kind": "Icon", "color": None, "image": url, - "size": height, + "size": size, "Z": 0 } else: - return {} + return None def _extractStroke(symbolLayers): for sl in symbolLayers: if sl["type"] == "CIMSolidStroke": - color = processColor(sl["color"]) + color = processColor(sl.get("color")) width = sl["width"] return color, width - return "#000000", 1 + return "#000000", 0 + +def _extractStrokeOpacity(symbolLayers): + for sl in symbolLayers: + if sl["type"] == "CIMSolidStroke": + try: + opacity = sl["color"]["values"][3] / 100 + except (KeyError, IndexError): + opacity = 1.0 + return opacity + return 1.0 def _extractFillColor(symbolLayers): for sl in symbolLayers: if sl["type"] == "CIMSolidFill": - color = processColor(sl["color"]) + color = processColor(sl.get("color")) return color - return "#000000" + return "#ffffff" + +def _extractFillOpacity(symbolLayers): + for sl in symbolLayers: + if sl["type"] == "CIMSolidFill": + try: + opacity = sl["color"]["values"][3] / 100 + except (KeyError, IndexError): + opacity = 1.0 + return opacity + return 1.0 def processColor(color): + if color is None: + return "#000000" values = color["values"] if color["type"] == "CIMRGBColor": - return '#%02x%02x%02x' % (values[0], values[1], values[2]) + return '#%02x%02x%02x' % (int(values[0]), int(values[1]), int(values[2])) elif color["type"] == 'CIMCMYKColor': r, g, b = cmyk2Rgb(values) return '#%02x%02x%02x' % (r, g, b) + elif color["type"] == 'CIMHSVColor': + r, g, b = hsv2rgb(values) + return '#%02x%02x%02x' % (int(r), int(g), int(b)) + elif color["type"] == 'CIMGrayColor': + return '#%02x%02x%02x' % (int(values[0]), int(values[0]), int(values[0])) else: return "#000000" @@ -326,8 +491,36 @@ def cmyk2Rgb(cmyk_array): y = cmyk_array[2] k = cmyk_array[3] - r = int((1 - ((c + k)/100)) * 255) - g = int((1 - ((m + k)/100)) * 255) - b = int((1 - ((y + k)/100)) * 255) + r = int(255* (1 - c / 100) * (1 - k / 100)) + g = int(255* (1 - m / 100) * (1 - k / 100)) + b = int(255* (1 - y / 100) * (1 - k / 100)) return r, g, b + + +def hsv2rgb(hsv_array): + h = hsv_array[0] / 360 + s = hsv_array[1] / 100 + v = hsv_array[2] / 100 + if s == 0.0: + v *= 255 + return (v, v, v) + i = int(h * 6.) + f = (h * 6.) - i + p = 255 * (v * (1. - s)) + q = 255 * (v * (1. - s * f)) + t = 255 * (v*(1. - s * (1. - f))) + v *= 255 + i %= 6 + if i == 0: + return (v, t, p) + if i == 1: + return (q, v, p) + if i == 2: + return (p, v, t) + if i == 3: + return (p, q, v) + if i == 4: + return (t, p, v) + if i == 5: + return (v, p, q) diff --git a/bridgestyle/geostyler/__init__.py b/bridgestyle/geostyler/__init__.py index e776073..3f3173d 100644 --- a/bridgestyle/geostyler/__init__.py +++ b/bridgestyle/geostyler/__init__.py @@ -2,8 +2,8 @@ def toGeostyler(style, options=None): - return json.loads(style) + return json.loads(style), [], [] def fromGeostyler(style, options=None): - return json.dumps(style) + return json.dumps(style), [], [] diff --git a/bridgestyle/mapboxgl/__init__.py b/bridgestyle/mapboxgl/__init__.py index da87f6d..141b871 100644 --- a/bridgestyle/mapboxgl/__init__.py +++ b/bridgestyle/mapboxgl/__init__.py @@ -7,5 +7,4 @@ def toGeostyler(style, options=None): def fromGeostyler(style, options=None): - mb, warnings = fromgeostyler.convert(style, options) - return mb + return fromgeostyler.convert(style, options) diff --git a/bridgestyle/mapserver/__init__.py b/bridgestyle/mapserver/__init__.py index 33d5bf7..6d60d57 100644 --- a/bridgestyle/mapserver/__init__.py +++ b/bridgestyle/mapserver/__init__.py @@ -8,4 +8,4 @@ def toGeostyler(style, options=None): def fromGeostyler(style, options=None): mb, symbols, warnings = fromgeostyler.convert(style, options) - return mb + return mb, warnings diff --git a/bridgestyle/qgis/expressions.py b/bridgestyle/qgis/expressions.py index a2c2cc9..4d75080 100644 --- a/bridgestyle/qgis/expressions.py +++ b/bridgestyle/qgis/expressions.py @@ -158,9 +158,7 @@ def handleUnary(node, layer): def handleLiteral(node): val = node.value() - quote = "" if isinstance(val, basestring): - quote = "'" val = val.replace("\n", "\\n") elif val is None: val = "null" diff --git a/bridgestyle/sld/__init__.py b/bridgestyle/sld/__init__.py index 31fe698..141b871 100644 --- a/bridgestyle/sld/__init__.py +++ b/bridgestyle/sld/__init__.py @@ -7,5 +7,4 @@ def toGeostyler(style, options=None): def fromGeostyler(style, options=None): - sld, warnings = fromgeostyler.convert(style, options) - return sld + return fromgeostyler.convert(style, options) diff --git a/bridgestyle/sld/fromgeostyler.py b/bridgestyle/sld/fromgeostyler.py index 8e37b58..6c13e15 100644 --- a/bridgestyle/sld/fromgeostyler.py +++ b/bridgestyle/sld/fromgeostyler.py @@ -4,37 +4,11 @@ from xml.etree.ElementTree import Element, SubElement from .transformations import processTransformation +from bridgestyle.version import __version__ _warnings = [] -# return a dictionary, where int is the Z value -# symbolizers are marked with a Z -# -# a rule (with multiple sybolizers) will have the rule replicated, one for each Z value found in the symbolizer -# -# ie. rule[0]["symbolizers"][0] has Z=0 -# rule[0]["symbolizers"][1] has Z=1 -# -# this will return -# result[0] => rule with symbolizer 0 (name changed to include Z=0) -# result[1] => rule with symbolizer 1 (name changed to include Z=1) -def processRulesByZ(rules): - result = {} - for rule in rules: - for symbolizer in rule.get("symbolizers", []): - z = symbolizer.get("Z", 0) - if z not in result: - result[z] = [] - r = result[z] - rule_copy = rule.copy() - rule_copy["symbolizers"] = [symbolizer] - rule_copy["name"] += f"{', ' if rule_copy['name'] else ''}Z={z}" - r.append(rule_copy) - - return result - - def convert(geostyler, options=None): global _warnings _warnings = [] @@ -47,8 +21,6 @@ def convert(geostyler, options=None): "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", } - rulesByZ = processRulesByZ(geostyler.get("rules", [])) - root = Element("StyledLayerDescriptor", attrib=attribs) namedLayer = SubElement(root, "NamedLayer") layerName = SubElement(namedLayer, "Name") @@ -57,18 +29,15 @@ def convert(geostyler, options=None): userStyleTitle = SubElement(userStyle, "Title") userStyleTitle.text = geostyler["name"] - z_values = list(rulesByZ.keys()) - z_values.sort() - for z_value in z_values: - zrules = rulesByZ[z_value] - featureTypeStyle = SubElement(userStyle, "FeatureTypeStyle") - if "transformation" in geostyler: - featureTypeStyle.append(processTransformation(geostyler["transformation"])) - for rule in zrules: - featureTypeStyle.append(processRule(rule)) - if "blendMode" in geostyler: - _addVendorOption(featureTypeStyle, "composite", geostyler["blendMode"]) + featureTypeStyle = SubElement(userStyle, "FeatureTypeStyle") + if "transformation" in geostyler: + featureTypeStyle.append(processTransformation(geostyler["transformation"])) + for rule in geostyler.get("rules", []): + featureTypeStyle.append(processRule(rule)) + if "blendMode" in geostyler: + _addVendorOption(featureTypeStyle, "composite", geostyler["blendMode"]) + root.insert(0, ElementTree.Comment(f'Generated by bridge_style ({__version__})')) sldstring = ElementTree.tostring(root, encoding="utf-8", method="xml").decode() dom = minidom.parseString(sldstring) result = dom.toprettyxml(indent=" ", encoding="utf-8").decode(), _warnings @@ -270,9 +239,9 @@ def _textSymbolizer(sl): followLine = sl.get("followLine", False) if followLine: _addVendorOption(root, "followLine", True) - _addVendorOption(root, "group", "yes") elif "background" not in sl: _addVendorOption(root, "autoWrap", 50) + _addVendorOption(root, "group", "yes") if "background" in sl: background = sl["background"] @@ -522,7 +491,7 @@ def _fillSymbolizer(sl, graphicFillLayer=0): # _addCssParameter(stroke, "stroke-linecap", cap) if outlineDasharray is not None: _addCssParameter( - stroke, "stroke-dasharray", " ".join(str(v) for v in outlineDasharray) + stroke, "stroke-dasharray", outlineDasharray ) if offset: @@ -544,6 +513,7 @@ def _fillSymbolizer(sl, graphicFillLayer=0): "PropertyIsLessThan", "PropertyIsGreaterThan", "PropertyIsLike", + "PropertyIsNull", "Add", "Sub", "Mul", diff --git a/bridgestyle/style2style.py b/bridgestyle/style2style.py index edb0d1c..f9d5c6e 100644 --- a/bridgestyle/style2style.py +++ b/bridgestyle/style2style.py @@ -1,10 +1,11 @@ import argparse import os +import shutil -import arcgis -import geostyler -import mapboxgl -import sld +from . import arcgis +from . import geostyler +from . import mapboxgl +from . import sld _exts = {"sld": sld, "geostyler": geostyler, "mapbox": mapboxgl, "lyrx": arcgis} @@ -22,14 +23,27 @@ def convert(fileA, fileB, options): with open(fileA) as f: styleA = f.read() - geostyler = _exts[extA].toGeostyler(styleA, options) - styleB = _exts[extB].fromGeostyler(geostyler, options) + geostyler, icons, geostylerwarnings = _exts[extA].toGeostyler(styleA, options) + if geostyler.get("rules", []): + styleB, warningsB = _exts[extB].fromGeostyler(geostyler, options) + outputfolder = os.path.dirname(fileB) + for f in icons: + dst = os.path.join(outputfolder, os.path.basename(f)) + shutil.copy(f, dst) - with open(fileB, "w") as f: - f.write(styleB) + with open(fileB, "w") as f: + f.write(styleB) + for w in geostylerwarnings + warningsB: + print(f"WARNING: {w}") + else: + for w in geostylerwarnings: + print(f"WARNING: {w}") + print("ERROR: Empty geostyler result (This is most likely caused by the " + "original style containing only unsupported elements)") -if __name__ == '__main__': + +def main(): parser = argparse.ArgumentParser() parser.add_argument('-c', action='store_true', help="Convert attribute names to lower case", diff --git a/bridgestyle/version.py b/bridgestyle/version.py new file mode 100644 index 0000000..7141c42 --- /dev/null +++ b/bridgestyle/version.py @@ -0,0 +1 @@ +__version__ = "0.1" \ No newline at end of file diff --git a/setup.py b/setup.py index 1534879..9ffba2b 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,9 @@ from setuptools import setup, find_packages +exec(open('bridgestyle/version.py').read()) setup( name="bridgestyle", - version="0.1", + version=__version__, author="GeoCat BV", author_email="volaya@geocat.net", description="A Python library to convert between different map style formats",