#!/usr/bin/python
""" Convert an ArcGIS Pro layer file (.lyrx) into a QGIS layer style (.qml).
River Architect's mapping runs on QGIS print layouts (riverarchitect.mapping).
Layer symbology moved with it: .lyrx -> .qml.
.lyrx is JSON (the Esri CIM format), so the class breaks, colours and labels of the
published lifespan symbology can be carried over exactly rather than re-invented.
Supported colorizers
--------------------
CIMRasterClassifyColorizer -> QGIS singlebandpseudocolor, DISCRETE interpolation
CIMRasterUniqueValueColorizer -> QGIS paletted/unique values
Anything else is reported and skipped.
Usage
-----
python -m riverarchitect.tools.lyrx2qml <input.lyrx> [output.qml]
Pure standard library - no QGIS, GDAL or arcpy needed.
"""
import argparse
import colorsys
import json
import os
import sys
from xml.etree import ElementTree as ET
from xml.dom import minidom
[docs]
def cim_color_to_rgba(color):
"""Convert a CIM colour of any flavour into an (r, g, b, a) 0-255 tuple."""
if not color:
return (0, 0, 0, 255)
ctype = color.get("type", "")
values = color.get("values", [])
if ctype == "CIMRGBColor":
r, g, b = values[0], values[1], values[2]
alpha = values[3] if len(values) > 3 else 100
elif ctype == "CIMHSVColor":
# CIM stores H in degrees 0-360, S and V as percentages.
h, s, v = values[0] / 360.0, values[1] / 100.0, values[2] / 100.0
r, g, b = [c * 255.0 for c in colorsys.hsv_to_rgb(h, s, v)]
alpha = values[3] if len(values) > 3 else 100
elif ctype == "CIMCMYKColor":
c, m, y, k = [v / 100.0 for v in values[:4]]
r = 255.0 * (1 - c) * (1 - k)
g = 255.0 * (1 - m) * (1 - k)
b = 255.0 * (1 - y) * (1 - k)
alpha = values[4] if len(values) > 4 else 100
elif ctype == "CIMGrayColor":
r = g = b = values[0] * 2.55
alpha = values[1] if len(values) > 1 else 100
else:
print(" WARNING: unsupported colour type %s - falling back to black." % ctype)
return (0, 0, 0, 255)
return (int(round(r)), int(round(g)), int(round(b)), int(round(alpha * 2.55)))
[docs]
def find_colorizer(document):
for layer in document.get("layerDefinitions", []):
if "colorizer" in layer:
return layer.get("name", "layer"), layer["colorizer"]
return None, None
[docs]
def build_pseudocolor_qml(colorizer):
"""QGIS singlebandpseudocolor renderer from a CIMRasterClassifyColorizer."""
breaks = colorizer.get("classBreaks", [])
if not breaks:
raise ValueError("no classBreaks in colorizer")
nodata_rgba = cim_color_to_rgba(colorizer.get("noDataColor"))
qgis = ET.Element("qgis", {"version": "3.34", "styleCategories": "AllStyleCategories"})
pipe = ET.SubElement(qgis, "pipe")
renderer = ET.SubElement(pipe, "rasterrenderer", {
"type": "singlebandpseudocolor",
"band": "1",
"opacity": "1",
"alphaBand": "-1",
"classificationMin": str(_lower_bound(breaks)),
"classificationMax": str(breaks[-1]["upperBound"]),
"nodataColor": "#%02x%02x%02x" % nodata_rgba[:3],
})
shader = ET.SubElement(renderer, "rastershader")
ramp = ET.SubElement(shader, "colorrampshader", {
# DISCRETE reproduces "everything up to upperBound gets this colour", which is
# exactly what an ArcGIS classify colorizer does.
"colorRampType": "DISCRETE",
"classificationMode": "1",
"clip": "0",
"labelPrecision": "2",
"minimumValue": str(_lower_bound(breaks)),
"maximumValue": str(breaks[-1]["upperBound"]),
})
for brk in breaks:
r, g, b, a = cim_color_to_rgba(brk.get("color"))
ET.SubElement(ramp, "item", {
"value": str(brk["upperBound"]),
"label": str(brk.get("label", brk["upperBound"])).strip(),
"color": "#%02x%02x%02x" % (r, g, b),
"alpha": str(a),
})
# NoData transparent, matching the .lyrx noDataColor alpha of 0.
if nodata_rgba[3] == 0:
ET.SubElement(pipe, "nodata")
ET.SubElement(qgis, "blendMode").text = "0"
return qgis
[docs]
def build_paletted_qml(colorizer):
"""QGIS paletted renderer from a CIMRasterUniqueValueColorizer."""
qgis = ET.Element("qgis", {"version": "3.34", "styleCategories": "AllStyleCategories"})
pipe = ET.SubElement(qgis, "pipe")
renderer = ET.SubElement(pipe, "rasterrenderer", {
"type": "paletted", "band": "1", "opacity": "1", "alphaBand": "-1"})
palette = ET.SubElement(renderer, "colorPalette")
for group in colorizer.get("groups", []):
for cls in group.get("classes", []):
values = cls.get("values", [])
if not values:
continue
r, g, b, a = cim_color_to_rgba(cls.get("color"))
ET.SubElement(palette, "paletteEntry", {
"value": str(values[0]),
"label": str(cls.get("label", values[0])).strip(),
"color": "#%02x%02x%02x" % (r, g, b),
"alpha": str(a),
})
return qgis
def _lower_bound(breaks):
"""Sensible classification minimum: one step below the first upper bound."""
first = breaks[0]["upperBound"]
return first - 1 if len(breaks) < 2 else first - (breaks[1]["upperBound"] - first)
[docs]
def convert(lyrx_path, qml_path=None):
with open(lyrx_path, "r", encoding="utf-8") as handle:
document = json.load(handle)
name, colorizer = find_colorizer(document)
if colorizer is None:
print("ERROR: no colorizer found in %s" % lyrx_path)
return None
ctype = colorizer.get("type")
print(" layer '%s', colorizer %s" % (name, ctype))
if ctype == "CIMRasterClassifyColorizer":
root = build_pseudocolor_qml(colorizer)
n = len(colorizer.get("classBreaks", []))
print(" converted %d class breaks" % n)
elif ctype == "CIMRasterUniqueValueColorizer":
root = build_paletted_qml(colorizer)
print(" converted unique-value palette")
else:
print("ERROR: unsupported colorizer type %s" % ctype)
return None
if qml_path is None:
qml_path = os.path.splitext(lyrx_path)[0] + ".qml"
pretty = minidom.parseString(ET.tostring(root, "utf-8")).toprettyxml(indent=" ")
with open(qml_path, "w", encoding="utf-8") as handle:
handle.write(pretty)
print(" wrote %s" % qml_path)
return qml_path
[docs]
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("lyrx", help="input .lyrx file")
parser.add_argument("qml", nargs="?", help="output .qml file (default: alongside input)")
args = parser.parse_args()
if not os.path.isfile(args.lyrx):
print("ERROR: no such file: %s" % args.lyrx)
return 1
print("Converting %s" % args.lyrx)
return 0 if convert(args.lyrx, args.qml) else 1
if __name__ == "__main__":
sys.exit(main())