import colorsys
import os
import hashlib
import json
import sys
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
TESTS = Path(__file__).resolve().parent
if len(sys.argv) != 2:
sys.exit(f"usage: python3 {sys.argv[0]} path/to/project.qgz")
QGZ = Path(sys.argv[1]).resolve()
QGS_NAME = QGZ.with_suffix(".qgs").name
QGIS_VERSION = "4.2.2-Belém do Pará"
KINDS = {
"Point": ("Point", "Point"),
"MultiPoint": ("Point", "Point"),
"LineString": ("Line", "LineString"),
"MultiLineString": ("Line", "LineString"),
"Polygon": ("Polygon", "Polygon"),
"MultiPolygon": ("Polygon", "Polygon"),
}
WGS84 = {
"proj4": "+proj=longlat +datum=WGS84 +no_defs",
"srsid": "3452",
"srid": "4326",
"authid": "EPSG:4326",
"description": "WGS 84",
"projectionacronym": "longlat",
"ellipsoidacronym": "EPSG:7030",
"geographicflag": "true",
}
OUTLINE = "35,35,35,255"
GRID_PIXEL_UNITS = {
f"{k}_unit": "Pixel" for k in ("line_width", "customdash", "dash_pattern_offset", "offset", "trim_distance_start", "trim_distance_end")
}
GRID_ROLES = [
("buffer", {"line_color": "236,1,1,255", "line_style": "dot", "line_width": "0.7", "customdash": "1;4", "capstyle": "square", "joinstyle": "bevel"}),
("grid", {"line_color": "35,35,35,255", "line_style": "dash", "line_width": "1", "customdash": "2;5", "capstyle": "round", "joinstyle": "round"}),
("zero", {"line_color": "72,123,182,255", "line_style": "solid", "line_width": "2", "customdash": "0.66;2", "capstyle": "round", "joinstyle": "round"}),
]
def relative_source(path):
rel = Path(os.path.relpath(path, QGZ.parent)).as_posix()
return rel if rel.startswith("../") else "./" + rel
GRID_SOURCE = relative_source(TESTS / "grid.geojson")
def walk_coords(coords, out):
if coords and isinstance(coords[0], (int, float)):
out.append(coords[:2])
else:
for c in coords or []:
walk_coords(c, out)
def file_layers(path):
data = json.loads(path.read_text())
features = data.get("features", []) if data.get("type") == "FeatureCollection" else [data]
by_kind = {}
for f in features:
geom = f.get("geometry") or {}
if geom.get("type") not in KINDS:
continue
kind, sublayer = KINDS[geom["type"]]
entry = by_kind.setdefault(kind, {"sublayer": sublayer, "types": set(), "coords": []})
entry["types"].add(geom["type"])
walk_coords(geom.get("coordinates"), entry["coords"])
rel = relative_source(path)
layers = []
for kind in ("Point", "Line", "Polygon"):
if kind not in by_kind:
continue
e = by_kind[kind]
source = rel if len(by_kind) == 1 else f"{rel}|geometrytype={e['sublayer']}"
multi = any(t.startswith("Multi") for t in e["types"])
wkb = ("Multi" if multi else "") + e["sublayer"]
xs = [c[0] for c in e["coords"]] or [0]
ys = [c[1] for c in e["coords"]] or [0]
layers.append((source, kind, wkb, (min(xs), min(ys), max(xs), max(ys))))
return layers
def color_for(source):
h = int(hashlib.md5(source.encode()).hexdigest()[:8], 16)
r, g, b = colorsys.hsv_to_rgb((h % 360) / 360, 0.5 + (h >> 9) % 30 / 100, 0.75 + (h >> 17) % 20 / 100)
return ",".join(str(round(c * 255)) for c in (r, g, b)) + ",255"
def layer_id(source):
name = Path(source.split("|")[0]).name.removesuffix(".geojson")
digest = hashlib.md5(source.encode()).hexdigest()[:12]
return name.replace("-", "_").replace(".", "_") + "_" + digest
def layer_name(source):
return Path(source.split("|")[0]).name.removesuffix(".geojson")
def sub(parent, tag, text=None, **attrib):
e = ET.SubElement(parent, tag, attrib)
if text is not None:
e.text = text
return e
def spatialrefsys(parent):
sys_ = sub(parent, "spatialrefsys", nativeFormat="Wkt")
for k, v in WGS84.items():
sub(sys_, k, v)
def srs(parent, tag):
spatialrefsys(sub(parent, tag))
def extent(parent, tag, box):
e = sub(parent, tag)
for name, value in zip(("xmin", "ymin", "xmax", "ymax"), box):
sub(e, name, f"{value:g}")
def options(parent, values):
m = sub(parent, "Option", type="Map")
for k, v in values.items():
sub(m, "Option", name=k, type="QString", value=v)
def line_options(color, width):
return {
"capstyle": "square",
"joinstyle": "bevel",
"line_color": color,
"line_style": "solid",
"line_width": width,
"line_width_unit": "MM",
}
def symbol(parent, kind, name, color, opts=None):
sym_type, cls = {"Point": ("marker", "SimpleMarker"), "Line": ("line", "SimpleLine"), "Polygon": ("fill", "SimpleFill")}[kind]
s = sub(parent, "symbol", alpha="1", clip_to_extent="1", force_rhr="0", name=name, type=sym_type)
if opts is None:
opts = {
"Point": {
"name": "circle",
"color": color,
"size": "2",
"size_unit": "MM",
"outline_color": OUTLINE,
"outline_style": "solid",
"outline_width": "0",
"outline_width_unit": "MM",
},
"Line": line_options(color, "0.26"),
"Polygon": {
"color": color,
"style": "solid",
"outline_color": OUTLINE,
"outline_style": "solid",
"outline_width": "0.26",
"outline_width_unit": "MM",
"joinstyle": "bevel",
},
}[kind]
options(ET.SubElement(s, "layer", {"class": cls, "enabled": "1", "locked": "0", "pass": "0"}), opts)
return s
def single_renderer(parent, kind, color):
r = sub(parent, "renderer-v2", type="singleSymbol", enableorderby="0", forceraster="0", symbollevels="0", referencescale="-1")
symbol(sub(r, "symbols"), kind, "0", color)
def grid_renderer(parent):
r = sub(parent, "renderer-v2", type="categorizedSymbol", attr="role", enableorderby="0", forceraster="0", symbollevels="0", referencescale="-1")
cats, syms = sub(r, "categories"), sub(r, "symbols")
for i, (role, style) in enumerate(GRID_ROLES):
sub(cats, "category", label=role, render="true", symbol=str(i), type="string", value=role)
symbol(syms, "Line", str(i), style["line_color"], {**style, **GRID_PIXEL_UNITS, "use_custom_dash": "1"})
other = str(len(GRID_ROLES))
sub(cats, "category", label="", render="true", symbol=other, type="NULL", value="NULL")
symbol(syms, "Line", other, "80,73,219,255")
def map_layer(parent, source, kind, wkb, box):
m = sub(parent, "maplayer", type="vector", geometry=kind, wkbType=wkb, layerType="Vector", hasScaleBasedVisibilityFlag="0", minScale="100000000", maxScale="0", readOnly="0", styleCategories="AllStyleCategories", labelsEnabled="0")
extent(m, "extent", box)
extent(m, "wgs84extent", box)
sub(m, "id", layer_id(source))
sub(m, "datasource", source)
sub(m, "layername", layer_name(source))
srs(m, "srs")
sub(m, "provider", "ogr", encoding="UTF-8")
if source == GRID_SOURCE:
grid_renderer(m)
else:
single_renderer(m, kind, color_for(source))
sub(m, "blendMode", "0")
sub(m, "featureBlendMode", "0")
sub(m, "layerOpacity", "1")
def read_view_state(path):
if not path.exists():
return {}, {}, None
try:
with zipfile.ZipFile(path) as z:
root = ET.fromstring(z.read(next(n for n in z.namelist() if n.endswith(".qgs"))))
except (zipfile.BadZipFile, StopIteration, ET.ParseError) as e:
print(f"ignoring the view state of {path.name}: {e}")
return {}, {}, None
groups, layers = {}, {}
def walk(group, prefix):
for g in group.findall("layer-tree-group"):
p = prefix + (g.get("name"),)
groups[p] = (g.get("checked"), g.get("expanded"))
walk(g, p)
for n in group.findall("layer-tree-layer"):
layers[n.get("source")] = (n.get("checked"), n.get("expanded"))
tree = root.find("layer-tree-group")
if tree is not None:
walk(tree, ())
e = root.find("mapcanvas/extent")
box = None if e is None else tuple(float(e.find(k).text) for k in ("xmin", "ymin", "xmax", "ymax"))
return groups, layers, box
def scan():
tree = {"files": [], "dirs": {}}
for path in sorted(TESTS.rglob("*.geojson"), key=lambda p: p.relative_to(TESTS).parts):
rel = path.relative_to(TESTS).parts
if any(part.startswith(".") for part in rel) or "target" in rel:
continue
node = tree
for part in rel[:-1]:
node = node["dirs"].setdefault(part, {"files": [], "dirs": {}})
node["files"].extend(file_layers(path))
return tree
def main():
old_groups, old_layers, old_extent = read_view_state(QGZ)
tree = scan()
root = ET.Element("qgis", projectname="", version=QGIS_VERSION)
sub(root, "homePath", path="")
sub(root, "title")
srs(root, "projectCrs")
layer_tree = sub(root, "layer-tree-group")
sub(sub(layer_tree, "customproperties"), "Option")
canvas = sub(root, "mapcanvas", name="theMapCanvas", annotationsVisible="1")
sub(canvas, "units", "degrees")
project_layers = sub(root, "projectlayers")
layer_order = sub(root, "layerorder")
order, boxes = [], []
def build(node, path, parent):
for source, kind, wkb, box in node["files"]:
map_layer(project_layers, source, kind, wkb, box)
order.append(layer_id(source))
boxes.append(box)
checked, expanded = old_layers.get(source, ("Qt::Checked", "0"))
n = sub(parent, "layer-tree-layer", checked=checked, expanded=expanded, id=layer_id(source), name=layer_name(source), providerKey="ogr", source=source, legend_exp="", legend_split_behavior="0", patch_size="-1,-1")
sub(sub(n, "customproperties"), "Option")
for name in sorted(node["dirs"]):
p = path + (name,)
checked, expanded = old_groups.get(p, ("Qt::Unchecked", "0"))
g = sub(parent, "layer-tree-group", checked=checked, expanded=expanded, groupLayer="", name=name)
g.set("wms-group-request-mode", "Normal")
sub(sub(g, "customproperties"), "Option")
build(node["dirs"][name], p, g)
build(tree, (), layer_tree)
for i in order:
sub(layer_order, "layer", id=i)
if old_extent is None:
grid = next((f[3] for f in tree["files"] if f[0] == GRID_SOURCE), None)
xs = [b[0] for b in boxes] + [b[2] for b in boxes]
ys = [b[1] for b in boxes] + [b[3] for b in boxes]
lo_x, lo_y, hi_x, hi_y = grid or (min(xs), min(ys), max(xs), max(ys))
pad = 0.05 * max(hi_x - lo_x, hi_y - lo_y)
old_extent = (lo_x - pad, lo_y - pad, hi_x + pad, hi_y + pad)
extent(canvas, "extent", old_extent)
sub(canvas, "rotation", "0")
srs(canvas, "destinationsrs")
view = sub(root, "ProjectViewSettings", UseProjectScales="0", rotation="0")
sub(view, "Scales")
corners = dict(zip(("xmin", "ymin", "xmax", "ymax"), (f"{v:g}" for v in old_extent)))
spatialrefsys(sub(view, "DefaultViewExtent", **corners))
props = sub(root, "properties", name="properties")
paths = sub(props, "properties", name="Paths")
sub(paths, "properties", "false", name="Absolute", type="bool")
crs = sub(props, "properties", name="SpatialRefSys")
sub(crs, "properties", "1", name="ProjectionsEnabled", type="int")
ET.indent(root, space=" ")
xml = ET.tostring(root, encoding="unicode")
with zipfile.ZipFile(QGZ, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(QGS_NAME, "<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>\n" + xml + "\n")
print(f"{QGZ.name}: {len(order)} layers")
if __name__ == "__main__":
main()