Skip to content

Commit 29f72cf

Browse files
authored
Add support for Leaflet Styles (#3)
* Add .gitattributes * Use newest qgis_plugin_tools * Add support for multiple style types: SimpleStyle and PointStyle * Multiply with symbol opacity and store widths as float * Enable fill and stroke fields for PointStyle * Use always canvas crs in bounding box chooser
1 parent 83b192b commit 29f72cf

25 files changed

Lines changed: 1289 additions & 137 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.gitattributes export-ignore
2+
.editorconfig export-ignore
3+
test export-ignore
4+

GemeindescanExporter/core/datapackage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def create_snapshot(self, snapshot_name: str, snapshot_config: SnapshotConfig, s
6262
snapshot.resources = []
6363

6464
for styled_layer in styled_layers:
65-
resource = Resource(styled_layer.resource_name, mediatype='application/geo+json',
65+
resource = Resource(styled_layer.resource_name, mediatype=styled_layer.style_type.media_type,
6666
data=styled_layer.get_geojson_data())
6767
snapshot.resources.append(resource)
6868

GemeindescanExporter/core/processing/algorithms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class StyleToAttributesAlg(BaseProcessingAlgorithm):
4141
PRIMARY = 'PRIMARY'
4242
OUTPUT = 'OUTPUT'
4343
OUTPUT_LEGEND = 'OUTPUT_LEGEND'
44+
OUTPUT_STYLE_TYPE = 'OUTPUT_STYLE_TYPE'
4445
EXTENT = 'EXTENT'
4546

4647
def name(self) -> str:
@@ -103,5 +104,6 @@ def processAlgorithm(self, parameters: Dict[str, Any], context: QgsProcessingCon
103104

104105
ret_val = {self.OUTPUT: dest_id,
105106
self.OUTPUT_LEGEND: wrkr.get_legend(),
107+
self.OUTPUT_STYLE_TYPE: wrkr.style_type.name
106108
}
107109
return ret_val

GemeindescanExporter/core/styles2attributes.py

Lines changed: 80 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,24 @@
1717
# You should have received a copy of the GNU General Public License
1818
# along with GemeindescanExporter. If not, see <https://www.gnu.org/licenses/>.
1919

20-
from typing import Optional, Dict
20+
from typing import Optional, Dict, List
2121

2222
from PyQt5.QtCore import QVariant
2323
from qgis.core import (QgsVectorLayer, QgsFields, QgsField, QgsFeatureSink, QgsFillSymbol, QgsLineSymbol,
2424
QgsFeature, QgsProcessingFeedback, QgsRectangle, QgsSpatialIndex, QgsFeatureRequest,
25-
QgsMarkerSymbol, QgsSymbol, QgsSymbolLayer)
25+
QgsMarkerSymbol, QgsSymbol, QgsSymbolLayer, QgsPropertyCollection, QgsProperty)
2626

27+
from ..definitions.style import Style, PointStyle
2728
from ..definitions.symbols import SymbolLayerType, SymbolType
29+
from ..definitions.types import StyleType
2830
from ..model.snapshot import Legend
31+
from ..qgis_plugin_tools.tools.exceptions import QgsPluginNotImplementedException
2932
from ..qgis_plugin_tools.tools.i18n import tr
3033

3134

3235
class StylesToAttributes:
33-
field_mapper = {"fill": "fillColor", "fill-opacity": "fillOpacity", "stroke": "strokeColor",
34-
"stroke-opacity": "strokeOpacity", "stroke-width": "strokeWidth", "type": "shape"}
35-
36-
DEFAULT_TEMPLATE = {
37-
"fill": "#000000",
38-
"fill-opacity": 1.0,
39-
"stroke": "#ffffff",
40-
"stroke-opacity": 1.0,
41-
"stroke-width": 1.0
42-
}
4336

4437
def __init__(self, layer: QgsVectorLayer, layer_name: str, feedback: QgsProcessingFeedback,
45-
field_template: Optional = None,
4638
primary_layer: bool = False):
4739
self.layer = layer
4840
self.layer_name = layer_name
@@ -51,6 +43,9 @@ def __init__(self, layer: QgsVectorLayer, layer_name: str, feedback: QgsProcessi
5143
self.renderer = self.layer.renderer()
5244
self.symbol_type: SymbolType = SymbolType[self.renderer.type()]
5345

46+
self.style_type: StyleType = StyleType.from_layer(layer)
47+
self.field_template = self.style_type.get_style().to_dict()
48+
5449
if self.symbol_type in [SymbolType.categorizedSymbol, SymbolType.graduatedSymbol]:
5550
self.mapped_col = self.renderer.classAttribute()
5651
else:
@@ -59,9 +54,6 @@ def __init__(self, layer: QgsVectorLayer, layer_name: str, feedback: QgsProcessi
5954
self.symbols = {}
6055
self.primary_layer = primary_layer
6156

62-
field_template = field_template if field_template is not None else StylesToAttributes.DEFAULT_TEMPLATE
63-
self.field_template = field_template.copy()
64-
6557
self.fields: QgsFields = self._generate_fields()
6658
self.legend = {}
6759

@@ -75,61 +67,80 @@ def _rgb_extract(prop):
7567
def get_legend(self) -> Dict:
7668
return {label: legend.to_dict() for label, legend in self.legend.items()}
7769

70+
def get_symbols(self) -> Dict:
71+
return {key: {**val, 'style': val['style'].to_dict()} for key, val in self.symbols.items()}
72+
7873
def extract_styles_to_layer(self, sink: QgsFeatureSink, extent: Optional[QgsRectangle] = None):
7974
try:
8075
self._update_symbols()
81-
self._update_legend()
8276
self._copy_fields(sink, extent)
77+
self._update_legend()
8378
except Exception as e:
8479
self.feedback.reportError(tr('Error occurred: {}', e), True)
8580
self.feedback.cancel()
8681

87-
def _get_style(self, symbol: QgsSymbol):
82+
def _get_style(self, symbol: QgsSymbol) -> Style:
8883
self.feedback.pushDebugInfo(str(type(symbol)))
8984

90-
style = self.field_template.copy()
85+
symbol_opacity: float = symbol.opacity()
86+
9187
symbol_layer: QgsSymbolLayer = symbol.symbolLayers()[0]
9288
if symbol_layer.subSymbol() is not None:
9389
return self._get_style(symbol_layer.subSymbol())
9490

91+
style: Style = self.style_type.get_style()
9592
sym_type = SymbolLayerType[symbol_layer.layerType()]
9693
sym = symbol_layer.properties()
94+
95+
# Add data defined properties for the style
96+
if symbol_layer.hasDataDefinedProperties():
97+
data_defined_props: QgsPropertyCollection = symbol_layer.dataDefinedProperties()
98+
for key in data_defined_props.propertyKeys():
99+
prop: QgsProperty = data_defined_props.property(key)
100+
if prop.field() != '':
101+
style.add_data_defined_expression(prop.field(), prop.asExpression())
102+
97103
if isinstance(symbol, QgsFillSymbol):
98104
if sym_type == SymbolLayerType.SimpleLine:
99-
style["type"] = "line"
100-
style["fill"] = "#000000"
101-
style["fill-opacity"] = 0
102-
style["stroke"] = self._rgb_extract(sym['outline_color'])[0]
103-
style["stroke-opacity"] = self._rgb_extract(sym['outline_color'])[1]
104-
style["stroke-width"] = sym['outline_width']
105+
style.type = "line"
106+
style.fill = "#000000"
107+
style.fill_opacity = 0
108+
style.stroke = self._rgb_extract(sym['outline_color'])[0]
109+
style.stroke_opacity = symbol_opacity * self._rgb_extract(sym['outline_color'])[1]
110+
style.stroke_width = float(sym['outline_width'])
105111
if sym_type in [SymbolLayerType.CentroidFill, SymbolLayerType.SimpleFill]:
106112
if sym_type == SymbolLayerType.CentroidFill:
107-
style["type"] = "circle"
113+
style.type = "circle"
108114
else:
109-
style["type"] = "rectangle"
110-
style["fill"] = self._rgb_extract(sym['color'])[0]
111-
style["fill-opacity"] = self._rgb_extract(sym['color'])[1]
112-
style["stroke"] = self._rgb_extract(sym['outline_color'])[0]
113-
style["stroke-opacity"] = self._rgb_extract(sym['outline_color'])[1]
114-
style["stroke-width"] = sym['outline_width']
115+
style.type = "rectangle"
116+
style.fill = self._rgb_extract(sym['color'])[0]
117+
style.fill_opacity = symbol_opacity * self._rgb_extract(sym['color'])[1]
118+
style.stroke = self._rgb_extract(sym['outline_color'])[0]
119+
style.stroke_opacity = symbol_opacity * self._rgb_extract(sym['outline_color'])[1]
120+
style.stroke_width = float(sym['outline_width'])
115121

116122
elif isinstance(symbol, QgsLineSymbol):
117123
if sym_type == SymbolLayerType.SimpleLine:
118124
self.feedback.pushDebugInfo(symbol_layer.properties())
119-
style["type"] = "line"
120-
style["fill"] = "transparent"
121-
style["fill-opacity"] = 0
122-
style["stroke"] = self._rgb_extract(sym['line_color'])[0]
123-
style["stroke-opacity"] = self._rgb_extract(sym['line_color'])[1]
124-
style["stroke-width"] = sym['line_width']
125+
style.type = "line"
126+
style.fill = "transparent"
127+
style.fill_opacity = 0
128+
style.stroke = self._rgb_extract(sym['line_color'])[0]
129+
style.stroke_opacity = symbol_opacity * self._rgb_extract(sym['line_color'])[1]
130+
style.stroke_width = float(sym['line_width'])
125131
elif isinstance(symbol, QgsMarkerSymbol):
126132
if sym_type == SymbolLayerType.SimpleMarker:
127-
style["type"] = "circle"
128-
style["fill"] = self._rgb_extract(sym['color'])[0]
129-
style["fill-opacity"] = self._rgb_extract(sym['color'])[1]
130-
style["stroke"] = self._rgb_extract(sym['outline_color'])[0]
131-
style["stroke-opacity"] = self._rgb_extract(sym['outline_color'])[1]
132-
style["stroke-width"] = sym['outline_width']
133+
style: PointStyle
134+
style.type = "circle"
135+
style.fill = self._rgb_extract(sym['color'])[0]
136+
style.fill_opacity = symbol_opacity * self._rgb_extract(sym['color'])[1]
137+
style.has_fill = style.fill_opacity > 0.0
138+
style.stroke = self._rgb_extract(sym['outline_color'])[0]
139+
style.stroke_opacity = symbol_opacity * self._rgb_extract(sym['outline_color'])[1]
140+
style.stroke_width = float(sym['outline_width'])
141+
style.has_stroke = style.stroke_opacity > 0.0
142+
style.radius = sym['size']
143+
133144
else:
134145
raise ValueError(f"Unkown symbol type: {symbol_layer.layerType()}")
135146
return style
@@ -171,13 +182,12 @@ def _copy_fields(self, sink: QgsFeatureSink, extent: Optional[QgsRectangle] = No
171182
sink.addFeature(f, QgsFeatureSink.FastInsert)
172183
else:
173184
feat = QgsFeature()
174-
style_attributes = self._get_style_for_feature(f)
175-
176-
attributes = f.attributes() + [style_attributes[key] for key in
177-
sorted(style_attributes.keys())]
185+
attributes = self._get_attributes_for_feature(f)
178186
feat.setAttributes(attributes)
179187
feat.setGeometry(f.geometry())
180-
sink.addFeature(feat, QgsFeatureSink.FastInsert)
188+
succeeded = sink.addFeature(feat, QgsFeatureSink.FastInsert)
189+
if not succeeded:
190+
raise ValueError(tr('Could not add feature to target layer. Attributes: {}', attributes))
181191
self.feedback.setProgress(int(current * total))
182192

183193
def _generate_fields(self) -> QgsFields:
@@ -186,15 +196,19 @@ def _generate_fields(self) -> QgsFields:
186196
if field_template_name not in self.layer.fields().names():
187197
if isinstance(field_template_value, str):
188198
field = QgsField(field_template_name, QVariant.String)
189-
fields.append(field)
190199
elif isinstance(field_template_value, float):
191200
field = QgsField(field_template_name, QVariant.Double)
192-
fields.append(field)
201+
elif isinstance(field_template_value, bool):
202+
field = QgsField(field_template_name, QVariant.Bool)
203+
else:
204+
raise QgsPluginNotImplementedException(
205+
tr('Field type not implemented: {}', type(field_template_value)))
206+
fields.append(field)
193207

194208
return fields
195209

196-
def _get_style_for_feature(self, feature: QgsFeature):
197-
ret_val = {}
210+
def _get_attributes_for_feature(self, feature: QgsFeature) -> List[any]:
211+
attributes = {i: feature[field.name()] for i, field in enumerate(feature.fields().toList())}
198212
if self.symbol_type == SymbolType.graduatedSymbol:
199213
feature_value = feature[self.mapped_col]
200214
matched = None
@@ -205,7 +219,10 @@ def _get_style_for_feature(self, feature: QgsFeature):
205219
break
206220
if matched is not None:
207221
for field_name in self.field_template.keys():
208-
ret_val[self.fields.names().index(field_name)] = self.symbols[matched]['style'][field_name]
222+
style: Style = self.symbols[matched]['style']
223+
style.evaluate_data_defined_expressions(feature)
224+
attributes[self.fields.names().index(field_name)] = style.to_dict()[
225+
field_name]
209226

210227
elif self.symbol_type == SymbolType.categorizedSymbol:
211228
feature_value = feature[self.mapped_col]
@@ -216,22 +233,26 @@ def _get_style_for_feature(self, feature: QgsFeature):
216233
matched = index
217234
if matched is not None:
218235
for field_name in self.field_template.keys():
219-
ret_val[self.fields.names().index(field_name)] = self.symbols[matched]['style'][field_name]
236+
style = self.symbols[matched]['style']
237+
style.evaluate_data_defined_expressions(feature)
238+
attributes[self.fields.names().index(field_name)] = style.to_dict()[
239+
field_name]
220240

221241
elif self.symbol_type == SymbolType.singleSymbol:
222242
for field_name in self.field_template.keys():
223-
ret_val[self.fields.names().index(field_name)] = self.symbols[0]['style'][field_name]
243+
style = self.symbols[0]['style']
244+
style.evaluate_data_defined_expressions(feature)
245+
attributes[self.fields.names().index(field_name)] = style.to_dict()[field_name]
224246

225247
# TODO: Add more
226248

227-
return ret_val
249+
return [attributes[key] for key in sorted(attributes.keys())]
228250

229251
def _update_legend(self):
230252
legend = {}
231253
i = 0
232254
for index, item in self.symbols.items():
233-
legend_style = item["style"].copy()
234-
legend_style = {self.field_mapper[key]: value for key, value in legend_style.items()}
255+
legend_style = item["style"].legend_style
235256
legend_style['size'] = 1
236257
legend_style["primary"] = self.primary_layer and (i == 0 or i == (len(self.symbols.items()) - 1))
237258
legend_style["label"] = item["label"]
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Gispo Ltd., hereby disclaims all copyright interest in the program GemeindescanExporter
2+
# Copyright (C) 2020 Gispo Ltd (https://www.gispo.fi/).
3+
#
4+
#
5+
# This file is part of GemeindescanExporter.
6+
#
7+
# GemeindescanExporter is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU General Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# GemeindescanExporter is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with GemeindescanExporter. If not, see <https://www.gnu.org/licenses/>.
19+
from typing import Dict, Union
20+
21+
from qgis._core import QgsFeature, QgsExpression, QgsExpressionContext
22+
23+
24+
class Style:
25+
LEGEND_MAPPER = {'fill': 'fillColor', 'fill-opacity': 'fillOpacity', 'stroke': 'strokeColor',
26+
'stroke-opacity': 'strokeOpacity', 'stroke-width': 'strokeWidth', 'type': 'shape'}
27+
FIELD_MAPPER = {
28+
'fill': 'fill', 'fill-opacity': 'fill_opacity', 'stroke': 'stroke', 'stroke-opacity': 'stroke_opacity',
29+
'stroke-width': 'stroke_width'
30+
}
31+
32+
def __init__(self):
33+
self.fill = '#000000'
34+
self.fill_opacity = 1.0
35+
self.stroke = '#ffffff'
36+
self.stroke_opacity = 1.0
37+
self.stroke_width = 1.0
38+
39+
# Not in template
40+
self.type = ''
41+
42+
self.data_defined_expressions: Dict[str, QgsExpression] = {}
43+
44+
@property
45+
def legend_style(self) -> Dict[str, any]:
46+
values = self.to_dict(for_legend=True)
47+
return {self.LEGEND_MAPPER[key]: value for key, value in values.items()
48+
if key in self.LEGEND_MAPPER.keys()}
49+
50+
def add_data_defined_expression(self, field_name: str, expression: str):
51+
self.data_defined_expressions[field_name] = QgsExpression(expression)
52+
53+
def evaluate_data_defined_expressions(self, feature: QgsFeature):
54+
context = QgsExpressionContext()
55+
context.setFeature(feature)
56+
for fld_name, exp in self.data_defined_expressions.items():
57+
evaluate = exp.evaluate(context)
58+
self.__dict__[fld_name] = evaluate
59+
60+
def to_dict(self, for_legend: bool = False) -> Dict[str, any]:
61+
values_dict = self.__dict__
62+
values = {f_name: values_dict[c_name] for f_name, c_name in self.FIELD_MAPPER.items()}
63+
64+
if for_legend:
65+
values['type'] = self.type
66+
return values
67+
68+
69+
class SimpleStyle(Style):
70+
pass
71+
72+
73+
class PointStyle(Style):
74+
LEGEND_MAPPER = {'fillColor': 'fillColor', 'fillOpacity': 'fillOpacity', 'color': 'strokeColor',
75+
'opacity': 'strokeOpacity', 'weight': 'strokeWidth', 'type': 'shape'}
76+
FIELD_MAPPER = {
77+
'fill': 'has_fill',
78+
'fillColor': 'fill',
79+
'fillOpacity': 'fill_opacity',
80+
'stroke': 'has_stroke',
81+
'color': 'stroke',
82+
'opacity': 'stroke_opacity',
83+
'weight': 'stroke_width',
84+
'radius': 'radius',
85+
# 'title': 'title',
86+
}
87+
88+
def __init__(self):
89+
super().__init__()
90+
self.radius: Union[int, float, str] = 2.0 # based on QGIS default mm radius
91+
self.has_fill = True
92+
self.has_stroke = True
93+
self.title = ''

0 commit comments

Comments
 (0)