diff --git a/.github/workflows/test_plugin.yaml b/.github/workflows/test_plugin.yaml index a292710..9756923 100644 --- a/.github/workflows/test_plugin.yaml +++ b/.github/workflows/test_plugin.yaml @@ -25,7 +25,7 @@ jobs: strategy: matrix: - docker_tags: [release-3_22, release-3_28, release-3_30, latest] + docker_tags: [release-3_22, release-3_28, release-3_34, release-3_36] steps: diff --git a/felt/core/__init__.py b/felt/core/__init__.py index 748eb2c..ee20f70 100644 --- a/felt/core/__init__.py +++ b/felt/core/__init__.py @@ -36,3 +36,4 @@ from .thumbnail_manager import AsyncThumbnailManager # noqa from .workspaces_model import WorkspacesModel # noqa from .workspace import Workspace # noqa +from .fsl_converter import FslConverter, ConversionContext # noqa diff --git a/felt/core/api_client.py b/felt/core/api_client.py index 6d96813..bc68892 100644 --- a/felt/core/api_client.py +++ b/felt/core/api_client.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt API client - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt API client +""" import json from typing import ( @@ -42,7 +31,8 @@ from .s3_upload_parameters import S3UploadParameters from .enums import UsageType from .constants import ( - FELT_API_URL + FELT_API_URL, + FELT_APIV2_URL ) PLUGIN_VERSION = "0.7.0" @@ -61,6 +51,8 @@ class FeltApiClient: URL_IMPORT_ENDPOINT = '/maps/{}/layers/url_import' USAGE_ENDPOINT = '/internal/reports' RECENT_MAPS_ENDPOINT = '/maps/recent' + UPLOAD_V2_ENDPOINT = '/maps/{}/upload' + PATCH_STYLE_ENDPOINT = '/maps/{}/layers/{}/style' def __init__(self): # default headers to add to all requests @@ -84,11 +76,14 @@ def set_token(self, token: Optional[str]): pass @staticmethod - def build_url(endpoint: str) -> QUrl: + def build_url(endpoint: str, version: int = 1) -> QUrl: """ Returns the full url of the specified endpoint """ - return QUrl(FELT_API_URL + endpoint) + if version == 1: + return QUrl(FELT_API_URL + endpoint) + elif version == 2: + return QUrl(FELT_APIV2_URL + endpoint) @staticmethod def _to_url_query(parameters: Dict[str, object]) -> QUrlQuery: @@ -104,12 +99,13 @@ def _to_url_query(parameters: Dict[str, object]) -> QUrlQuery: query.addQueryItem(name, str(value)) return query - def _build_request(self, endpoint: str, headers=None, params=None) \ + def _build_request(self, endpoint: str, headers=None, params=None, + version: int = 1) \ -> QNetworkRequest: """ Builds a network request """ - url = self.build_url(endpoint) + url = self.build_url(endpoint, version) if params: url.setQuery(FeltApiClient._to_url_query(params)) @@ -281,6 +277,33 @@ def prepare_layer_upload(self, json_data.encode() ) + def prepare_layer_upload_v2(self, + map_id: str, + name: str, + feedback: Optional[QgsFeedback] = None) \ + -> Union[QNetworkReply, QgsNetworkReplyContent]: + """ + Prepares a layer upload, using v2 api + """ + request = self._build_request( + self.UPLOAD_V2_ENDPOINT.format(map_id), + {'Content-Type': 'application/json'}, + version=2 + ) + + request_params = { + 'name': name + } + + json_data = json.dumps(request_params) + reply = QgsNetworkAccessManager.instance().blockingPost( + request, + json_data.encode(), + feedback=feedback + ) + + return reply + def create_upload_file_request(self, filename: str, content: bytes, @@ -385,6 +408,29 @@ def finalize_layer_upload(self, json_data.encode() ) + def patch_style(self, + map_id: str, + layer_id: str, + fsl: Dict) \ + -> QNetworkReply: + """ + Patches a layer's style + """ + request = self._build_request( + self.PATCH_STYLE_ENDPOINT.format(map_id, layer_id), + {'Content-Type': 'application/json'} + ) + + style_post_data = { + 'style': json.dumps(fsl) + } + + return QgsNetworkAccessManager.instance().sendCustomRequest( + request, + b"PATCH", + json.dumps(style_post_data).encode() + ) + def report_usage(self, content: str, usage_type: UsageType = UsageType.Info) -> QNetworkReply: diff --git a/felt/core/auth.py b/felt/core/auth.py index 7be378e..f2d5f8b 100644 --- a/felt/core/auth.py +++ b/felt/core/auth.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt QGIS plugin - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt QGIS plugin +""" import json import urllib diff --git a/felt/core/constants.py b/felt/core/constants.py index 0b6268c..9bf8507 100644 --- a/felt/core/constants.py +++ b/felt/core/constants.py @@ -1,20 +1,10 @@ -# -*- coding: utf-8 -*- -"""Felt plugin constants - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt plugin constants +""" FELT_API_BASE = "https://felt.com" FELT_API_URL = f'{FELT_API_BASE}/api/v1' +FELT_APIV2_URL = f'{FELT_API_BASE}/api/v2' SIGNUP_URL = f'{FELT_API_BASE}/signup' TOS_URL = f'{FELT_API_BASE}/terms' PRIVACY_POLICY_URL = f'{FELT_API_BASE}/privacy' diff --git a/felt/core/enums.py b/felt/core/enums.py index 68cd991..465de92 100644 --- a/felt/core/enums.py +++ b/felt/core/enums.py @@ -1,19 +1,7 @@ -# -*- coding: utf-8 -*- """ Enums - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - from enum import ( Enum, auto diff --git a/felt/core/exceptions.py b/felt/core/exceptions.py index 1a490f9..27f8250 100644 --- a/felt/core/exceptions.py +++ b/felt/core/exceptions.py @@ -1,19 +1,7 @@ -# -*- coding: utf-8 -*- """ Custom exceptions - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - class LayerPackagingException(Exception): """ diff --git a/felt/core/fsl_converter.py b/felt/core/fsl_converter.py new file mode 100644 index 0000000..4d7d566 --- /dev/null +++ b/felt/core/fsl_converter.py @@ -0,0 +1,1689 @@ +""" +QGIS to FSL conversion +""" + +import math +from enum import ( + Enum, + auto +) +from typing import ( + Dict, + List, + Optional +) + +from qgis.PyQt.QtCore import ( + Qt, +) +from qgis.PyQt.QtGui import QColor +from qgis.core import ( + NULL, + QgsVectorLayer, + QgsRasterLayer, + QgsSymbol, + QgsSymbolLayer, + QgsSimpleFillSymbolLayer, + QgsShapeburstFillSymbolLayer, + QgsGradientFillSymbolLayer, + QgsSimpleLineSymbolLayer, + QgsLinePatternFillSymbolLayer, + QgsHashedLineSymbolLayer, + QgsMarkerLineSymbolLayer, + QgsSimpleMarkerSymbolLayer, + QgsEllipseSymbolLayer, + QgsSvgMarkerSymbolLayer, + QgsPointPatternFillSymbolLayer, + QgsCentroidFillSymbolLayer, + QgsFilledMarkerSymbolLayer, + QgsSVGFillSymbolLayer, + QgsFontMarkerSymbolLayer, + QgsRandomMarkerFillSymbolLayer, + QgsArrowSymbolLayer, + QgsRenderContext, + QgsUnitTypes, + QgsFeatureRenderer, + QgsSingleSymbolRenderer, + QgsNullSymbolRenderer, + QgsCategorizedSymbolRenderer, + QgsGraduatedSymbolRenderer, + QgsRuleBasedRenderer, + QgsPalLayerSettings, + QgsTextFormat, + QgsStringUtils, + QgsExpression, + QgsExpressionNode, + QgsExpressionNodeBinaryOperator, + QgsExpressionNodeInOperator, + QgsExpressionContext, + QgsRasterRenderer, + QgsSingleBandPseudoColorRenderer, + QgsPalettedRasterRenderer, + QgsSingleBandGrayRenderer, + QgsRasterPipe, + QgsRasterDataProvider +) + +from .map_utils import MapUtils + + +class LogLevel(Enum): + """ + Logging level + """ + Warning = auto() + Error = auto() + + +class ConversionContext: + + def __init__(self): + # TODO -- populate with correct dpi etc + self.render_context: QgsRenderContext = QgsRenderContext() + self.render_context.setScaleFactor(3.779) + + def push_warning(self, warning: str, level: LogLevel = LogLevel.Warning): + """ + Pushes a warning to the context + """ + + +class FslConverter: + NULL_COLOR = "rgba(0, 0, 0, 0)" + + @staticmethod + def expression_to_filter( + expression: str, + context: ConversionContext, + layer: Optional[QgsVectorLayer] = None + ) -> Optional[List]: + """ + Attempts to convert a QGIS expression to a FSL filter + + Returns None if conversion fails + """ + exp = QgsExpression(expression) + expression_context = QgsExpressionContext() + if layer: + expression_context.appendScope( + layer.createExpressionContextScope()) + + exp.prepare(expression_context) + + if exp.hasParserError(): + return None + + if exp.rootNode(): + success, res = ( + FslConverter.walk_expression(exp.rootNode(), context)) + if success: + return res + + return None + + @staticmethod + def walk_expression(node: QgsExpressionNode, context: ConversionContext): + """ + Visitor for QGIS expression nodes + """ + if node is None: + return False, None + elif node.nodeType() == QgsExpressionNode.ntBinaryOperator: + return FslConverter.handle_binary(node, context) + elif node.nodeType() == QgsExpressionNode.ntInOperator: + return FslConverter.handle_in(node, context) + elif node.nodeType() == QgsExpressionNode.ntLiteral: + return True, node.value() + elif node.nodeType() == QgsExpressionNode.ntColumnRef: + return True, node.name() + return False, None + + @staticmethod + def handle_binary(node: QgsExpressionNodeBinaryOperator, + context: ConversionContext): + """ + Convert a binary node + """ + left_ok, left = FslConverter.walk_expression(node.opLeft(), context) + if not left_ok: + return False, None + right_ok, right = FslConverter.walk_expression(node.opRight(), context) + if not right_ok: + return False, None + + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boEQ: + return True, [left, "in", [right]] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boNE: + return True, [left, "ni", [right]] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boGT: + return True, [left, "gt", right] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boLT: + return True, [left, "lt", right] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boGE: + return True, [left, "ge", right] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boLE: + return True, [left, "le", right] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boIs: + return True, [left, "is", right] + if node.op() == QgsExpressionNodeBinaryOperator.BinaryOperator.boIsNot: + return True, [left, "isnt", right] + return False, None + + @staticmethod + def handle_in(node: QgsExpressionNodeInOperator, + context: ConversionContext): + """ + Convert an In node + """ + left_ok, left = FslConverter.walk_expression(node.node(), context) + if not left_ok: + return False, None + + converted_list = [] + for v in node.list().list(): + val_ok, val = FslConverter.walk_expression(v, context) + if not val_ok: + return False, None + converted_list.append(val) + + if node.isNotIn(): + return True, [left, "ni", converted_list] + + return True, [left, "in", converted_list] + + @staticmethod + def vector_layer_to_fsl( + layer: QgsVectorLayer, + context: ConversionContext + ) -> Optional[Dict[str, object]]: + """ + Converts a vector layer to FSL + """ + fsl = FslConverter.vector_renderer_to_fsl( + layer.renderer(), context, layer.opacity() + ) + if not fsl: + return None + + if layer.hasScaleBasedVisibility(): + if layer.minimumScale(): + fsl['style']['minZoom'] = ( + MapUtils.map_scale_to_leaflet_tile_zoom( + layer.minimumScale())) + if layer.maximumScale(): + fsl['style']['maxZoom'] = ( + MapUtils.map_scale_to_leaflet_tile_zoom( + layer.maximumScale())) + + return fsl + + @staticmethod + def vector_renderer_to_fsl(renderer: QgsFeatureRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS vector renderer to FSL + """ + if not renderer: + return None + + RENDERER_CONVERTERS = { + QgsSingleSymbolRenderer: FslConverter.single_renderer_to_fsl, + QgsCategorizedSymbolRenderer: + FslConverter.categorized_renderer_to_fsl, + QgsGraduatedSymbolRenderer: + FslConverter.graduated_renderer_to_fsl, + QgsRuleBasedRenderer: + FslConverter.rule_based_renderer_to_fsl, + QgsNullSymbolRenderer: FslConverter.null_renderer_to_fsl, + + # Could potentially be supported: + # QgsHeatmapRenderer + + # No meaningful conversions for these types: + # Qgs25DRenderer + # QgsEmbeddedSymbolRenderer + # QgsPointClusterRenderer + # QgsPointDisplacementRenderer + # QgsInvertedPolygonRenderer + } + + for _class, converter in RENDERER_CONVERTERS.items(): + if isinstance(renderer, _class): + return converter(renderer, context, layer_opacity) + + context.push_warning('{} renderers cannot be converted yet'.format( + renderer.__class__.__name__), + LogLevel.Error) + return None + + @staticmethod + def single_renderer_to_fsl(renderer: QgsSingleSymbolRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS single symbol renderer to an FSL definition + """ + if not renderer.symbol(): + return None + + converted_symbol = FslConverter.symbol_to_fsl(renderer.symbol(), + context, + layer_opacity) + if not converted_symbol: + return None + + return { + "style": converted_symbol[0] if len(converted_symbol) == 1 + else converted_symbol, + "legend": {}, + "type": "simple" + } + + @staticmethod + def rule_based_renderer_to_fsl( + renderer: QgsRuleBasedRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS rule based renderer to an FSL definition + """ + if not renderer.rootRule().children(): + # treat no rules as a null renderer + return { + "style": { + "color": FslConverter.NULL_COLOR, + "strokeColor": FslConverter.NULL_COLOR + }, + "legend": {}, + "type": "simple" + } + + # single rule + if (len(renderer.rootRule().children()) == 1 and + not renderer.rootRule().children()[0].children()): + + target_rule = renderer.rootRule().children()[0] + converted_symbol = FslConverter.symbol_to_fsl( + target_rule.symbol(), + context, + layer_opacity) + if not converted_symbol: + return None + + res = { + "style": converted_symbol[0] if len(converted_symbol) == 1 + else converted_symbol, + "legend": {}, + "type": "simple" + } + + if target_rule.filterExpression(): + converted_filter = FslConverter.expression_to_filter( + target_rule.filterExpression(), + context + ) + if converted_filter is not None: + res['filters'] = converted_filter + + return res + + @staticmethod + def null_renderer_to_fsl(renderer: QgsNullSymbolRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS null renderer to an FSL definition + """ + return { + "style": { + "color": FslConverter.NULL_COLOR, + "strokeColor": FslConverter.NULL_COLOR + }, + "legend": {}, + "type": "simple" + } + + @staticmethod + def create_varying_style_from_list( + styles: List[List[Dict[str, object]]]) -> List[Dict[str, object]]: + """ + Given a list of individual styles, try to create a single + varying style from them + """ + if len(styles) < 1: + return [] + + result = [] + # upgrade all properties in first symbol to lists + first_symbol = styles[0] + for layer in first_symbol: + list_dict = {} + for key, value in layer.items(): + list_dict[key] = [value] + result.append(list_dict) + + for symbol in styles[1:]: + for layer_idx, target_layer in enumerate(result): + if layer_idx >= len(symbol): + source_layer = None + else: + source_layer = symbol[layer_idx] + + for key, value in target_layer.items(): + # if property doesn't exist in this layer, copy from first + # symbol + if source_layer and key in source_layer: + value.append(source_layer[key]) + else: + value.append(value[0]) + + return FslConverter.simplify_style(result) + + @staticmethod + def simplify_style(style: List[Dict[str, object]]) \ + -> List[Dict[str, object]]: + """ + Tries to simplify a style, by collapsing lists of the same + value to a single value + """ + # re-collapse single value lists to single values + cleaned_style = [] + for layer in style: + cleaned_layer = {} + for key, value in layer.items(): + if (isinstance(value, list) and + all(v == value[0] for v in value)): + cleaned_layer[key] = value[0] + else: + cleaned_layer[key] = value + cleaned_style.append(cleaned_layer) + return cleaned_style + + @staticmethod + def categorized_renderer_to_fsl( + renderer: QgsCategorizedSymbolRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS categorized symbol renderer to an FSL definition + """ + if not renderer.categories(): + return None + + converted_symbols = [] + category_values = [] + other_symbol = None + legend_text = {} + for category in renderer.categories(): + converted_symbol = FslConverter.symbol_to_fsl(category.symbol(), + context, + layer_opacity) + if converted_symbol: + if category.value() == NULL: + other_symbol = converted_symbol + legend_text['Other'] = category.label() + else: + converted_symbols.append(converted_symbol) + legend_text[str(category.value())] = category.label() + category_values.append(str(category.value())) + + all_symbols = converted_symbols + if other_symbol: + all_symbols.append(other_symbol) + + if not all_symbols: + return None + + style = FslConverter.create_varying_style_from_list( + all_symbols + ) + + return { + "config": { + "categoricalAttribute": renderer.classAttribute(), + "categories": category_values, + "showOther": bool(other_symbol) + }, + "legend": { + "displayName": legend_text + }, + "style": style, + "type": "categorical" + } + + @staticmethod + def graduated_renderer_to_fsl( + renderer: QgsGraduatedSymbolRenderer, + context: ConversionContext, + layer_opacity: float = 1) \ + -> Optional[Dict[str, object]]: + """ + Converts a QGIS categorized symbol renderer to an FSL definition + """ + if not renderer.ranges(): + return None + + converted_symbols = [] + range_breaks = [] + legend_text = {} + for idx, _range in enumerate(renderer.ranges()): + converted_symbol = FslConverter.symbol_to_fsl(_range.symbol(), + context, + layer_opacity) + if converted_symbol: + converted_symbols.append(converted_symbol) + legend_text[str(idx)] = _range.label() + if idx == 0: + range_breaks.append(_range.lowerValue()) + range_breaks.append(_range.upperValue()) + + if not converted_symbols: + return None + + style = FslConverter.create_varying_style_from_list( + converted_symbols + ) + + return { + "config": { + "numericAttribute": renderer.classAttribute(), + "steps": range_breaks + }, + "legend": { + "displayName": legend_text + }, + "style": style, + "type": "numeric" + } + + @staticmethod + def symbol_to_fsl(symbol: QgsSymbol, + context: ConversionContext, + opacity: float = 1) \ + -> List[Dict[str, object]]: + """ + Converts a QGIS symbol to an FSL definition + + Returns an empty list if no symbol should be used + """ + enabled_layers = [symbol[i] for i in range(len(symbol)) if + symbol[i].enabled()] + if not enabled_layers: + return [] + + symbol_opacity = opacity * symbol.opacity() + fsl_layers = [] + for layer in enabled_layers: + fsl_layers.extend( + FslConverter.symbol_layer_to_fsl(layer, context, + symbol_opacity) + ) + + return fsl_layers + + @staticmethod + def symbol_layer_to_fsl( + layer: QgsSymbolLayer, + context: ConversionContext, + symbol_opacity: float) -> List[Dict[str, object]]: + """ + Converts QGIS symbol layers to FSL symbol layers + """ + SYMBOL_LAYER_CONVERTERS = { + # Marker types + QgsSimpleMarkerSymbolLayer: FslConverter.simple_marker_to_fsl, + QgsEllipseSymbolLayer: FslConverter.ellipse_marker_to_fsl, + QgsSvgMarkerSymbolLayer: FslConverter.svg_marker_to_fsl, + QgsFontMarkerSymbolLayer: FslConverter.font_marker_to_fsl, + QgsFilledMarkerSymbolLayer: FslConverter.filled_marker_to_fsl, + + # Line types + QgsSimpleLineSymbolLayer: FslConverter.simple_line_to_fsl, + QgsMarkerLineSymbolLayer: FslConverter.marker_line_to_fsl, + QgsHashedLineSymbolLayer: FslConverter.hashed_line_to_fsl, + QgsArrowSymbolLayer: FslConverter.arrow_to_fsl, + + # Fill types + QgsSimpleFillSymbolLayer: FslConverter.simple_fill_to_fsl, + QgsShapeburstFillSymbolLayer: FslConverter.shapeburst_fill_to_fsl, + QgsGradientFillSymbolLayer: FslConverter.gradient_fill_to_fsl, + QgsLinePatternFillSymbolLayer: + FslConverter.line_pattern_fill_to_fsl, + QgsSVGFillSymbolLayer: FslConverter.svg_fill_to_fsl, + QgsPointPatternFillSymbolLayer: + FslConverter.point_pattern_fill_to_fsl, + QgsCentroidFillSymbolLayer: FslConverter.centroid_fill_to_fsl, + QgsRandomMarkerFillSymbolLayer: + FslConverter.random_marker_fill_to_fsl, + + # Nothing of interest here, there's NO properties we can convert! + # QgsRasterFillSymbolLayer + # QgsRasterMarkerSymbolLayer + # QgsAnimatedMarkerSymbolLayer + # QgsVectorFieldSymbolLayer + # QgsGeometryGeneratorSymbolLayer + # QgsInterpolatedLineSymbolLayer + # QgsRasterLineSymbolLayer + # QgsLineburstSymbolLayer + } + + for _class, converter in SYMBOL_LAYER_CONVERTERS.items(): + if isinstance(layer, _class): + return converter(layer, context, symbol_opacity) + + context.push_warning('{} symbol layers cannot be converted yet'.format( + layer.__class__.__name__), + LogLevel.Error) + return [] + + @staticmethod + def color_to_fsl(color: QColor, context: ConversionContext, + opacity: float = 1) -> Optional[str]: + """ + Converts a color to FSL, optionally reducing the opacity of the color + """ + if not color.isValid(): + return None + + color_opacity = color.alphaF() * opacity + if color_opacity == 1: + return 'rgb({}, {}, {})'.format(color.red(), + color.green(), + color.blue()) + else: + return 'rgba({}, {}, {}, {})'.format(color.red(), + color.green(), + color.blue(), + round(color_opacity, 2)) + + @staticmethod + def convert_to_pixels( + size, + size_unit: QgsUnitTypes.RenderUnit, + context: ConversionContext, + round_size: bool = True + ) -> float: + """ + Converts a size to pixels + """ + res = context.render_context.convertToPainterUnits( + size, size_unit + ) + return round(res) if round_size else res + + @staticmethod + def convert_stroke_to_pixels( + size, + size_unit: QgsUnitTypes.RenderUnit, + context: ConversionContext + ): + """ + Converts a stroke size to pixels + """ + if size == 0: + # handle hairline sizes + return 1 + + res = FslConverter.convert_to_pixels( + size, size_unit, context, True + ) + # round up to at least 1 pixel + return max(res, 1) + + @staticmethod + def convert_cap_style(style: Qt.PenCapStyle) -> str: + """ + Convert a Qt cap style to FSL + """ + return { + Qt.RoundCap: 'round', + Qt.SquareCap: 'square', + Qt.FlatCap: 'butt', + }[style] + + @staticmethod + def convert_join_style(style: Qt.PenJoinStyle) -> str: + """ + Convert a Qt join style to FSL + """ + return { + Qt.RoundJoin: 'round', + Qt.BevelJoin: 'bevel', + Qt.MiterJoin: 'miter', + Qt.SvgMiterJoin: 'miter', + }[style] + + @staticmethod + def convert_pen_style(style: Qt.PenStyle) -> List[float]: + """ + Converts a Qt pen style to an array of dash/space lengths + """ + return { + Qt.NoPen: [], + Qt.SolidLine: [], + Qt.DashLine: [2.5, 2], + Qt.DotLine: [0.5, 1.3], + Qt.DashDotLine: [0.5, 1.3, 2.5, 1.3], + Qt.DashDotDotLine: [0.5, 1.3, 0.5, 1.3, 2.5, 1.3] + }[style] + + @staticmethod + def simple_line_to_fsl( + layer: QgsSimpleLineSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS simple line symbol layer to FSL + """ + if (layer.penStyle() == Qt.NoPen or + not layer.color().isValid() or + layer.color().alphaF() == 0): + return [] + + color_str = FslConverter.color_to_fsl( + layer.color(), context + ) + stroke_width = FslConverter.convert_stroke_to_pixels( + layer.width(), + layer.widthUnit(), + context) + + res = { + 'color': color_str, + 'size': stroke_width, + 'lineCap': FslConverter.convert_cap_style(layer.penCapStyle()), + 'lineJoin': FslConverter.convert_join_style(layer.penJoinStyle()) + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + if layer.useCustomDashPattern(): + res['dashArray'] = [FslConverter.convert_to_pixels( + part, + layer.customDashPatternUnit(), context, round_size=False) for + part in layer.customDashVector()] + elif layer.penStyle() != Qt.SolidLine: + res['dashArray'] = FslConverter.convert_pen_style(layer.penStyle()) + + # not supported: + # - line offset + # - pattern offset + # - trim lines + + return [res] + + @staticmethod + def marker_line_to_fsl( + layer: QgsMarkerLineSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS marker line symbol layer to FSL + """ + marker_symbol = layer.subSymbol() + if marker_symbol is None: + return [] + + converted_marker = FslConverter.symbol_to_fsl(marker_symbol, context) + if not converted_marker: + return [] + + context.push_warning( + 'Marker lines are not supported, converting to a solid line') + + results = [] + for converted_layer in converted_marker: + color_str = converted_layer.get('color') + if not color_str: + continue + + res = { + 'color': color_str, + } + + if layer.placement() == QgsMarkerLineSymbolLayer.Interval: + interval_pixels = FslConverter.convert_to_pixels( + layer.interval(), layer.intervalUnit(), context) + try: + # FSL size is radius, not diameter + marker_size = float(converted_layer['size']) * 2 + except TypeError: + continue + + res['dashArray'] = [marker_size, interval_pixels - marker_size] + res['size'] = marker_size + else: + # hardcoded size, there's no point using the marker size as it + # will visually appear as a much fatter line due to the + # missing spaces between markers + res['size'] = 2 + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def hashed_line_to_fsl( + layer: QgsHashedLineSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS hashed line symbol layer to FSL + """ + hatch_symbol = layer.subSymbol() + if hatch_symbol is None: + return [] + + converted_hatch = FslConverter.symbol_to_fsl(hatch_symbol, context) + if not converted_hatch: + return [] + + context.push_warning( + 'Hatched lines are not supported, converting to a solid line') + + results = [] + for converted_layer in converted_hatch: + color_str = converted_layer.get('color') + if not color_str: + continue + + res = { + 'color': color_str, + } + + if layer.placement() == QgsMarkerLineSymbolLayer.Interval: + interval_pixels = FslConverter.convert_to_pixels( + layer.interval(), layer.intervalUnit(), context) + try: + hatch_size = float(converted_layer['size']) + except TypeError: + continue + + res['dashArray'] = [hatch_size, interval_pixels - hatch_size] + res['size'] = hatch_size + else: + # hardcoded size, there's no point using the marker size as it + # will visually appear as a much fatter line due to the + # missing spaces between markers + res['size'] = 1 + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def arrow_to_fsl( + layer: QgsArrowSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS arrow symbol layer to FSL + """ + fill_symbol = layer.subSymbol() + if fill_symbol is None: + return [] + + converted_fill = FslConverter.symbol_to_fsl(fill_symbol, context) + if not converted_fill: + return [] + + context.push_warning( + 'Arrows are not supported, converting to a solid line') + + results = [] + for converted_layer in converted_fill: + color_str = converted_layer.get('color') + if not color_str: + continue + + # take average of start/end width + size = 0.5 * (FslConverter.convert_to_pixels( + layer.arrowWidth(), + layer.arrowWidthUnit(), + context) + + FslConverter.convert_to_pixels( + layer.arrowStartWidth(), + layer.arrowStartWidthUnit(), + context)) + + res = { + 'color': color_str, + 'size': size + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def simple_fill_to_fsl( + layer: QgsSimpleFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS simple fill symbol layer to FSL + """ + has_invisible_fill = (layer.brushStyle() == Qt.NoBrush or + not layer.color().isValid() or + layer.color().alphaF() == 0) + has_invisible_stroke = (layer.strokeStyle() == Qt.NoPen or + not layer.strokeColor().isValid() or + layer.strokeColor().alphaF() == 0) + if has_invisible_fill and has_invisible_stroke: + return [] + + color_str = FslConverter.color_to_fsl( + layer.color(), context + ) + + res = { + 'color': color_str, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + if not has_invisible_stroke: + res['strokeColor'] = FslConverter.color_to_fsl(layer.strokeColor(), + context) + res['strokeWidth'] = FslConverter.convert_stroke_to_pixels( + layer.strokeWidth(), layer.strokeWidthUnit(), context) + res['lineJoin'] = FslConverter.convert_join_style( + layer.penJoinStyle()) + + if layer.strokeStyle() != Qt.SolidLine: + res['dashArray'] = FslConverter.convert_pen_style( + layer.strokeStyle()) + else: + res['strokeColor'] = FslConverter.NULL_COLOR + + # not supported: + # - fill offset + # - fill style + + return [res] + + @staticmethod + def simple_marker_to_fsl( + layer: QgsSimpleMarkerSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS simple marker symbol layer to FSL + """ + has_fill = layer.color().isValid() and layer.color().alphaF() > 0 + has_stroke = (layer.strokeColor().alphaF() > 0 and + layer.strokeStyle() != Qt.NoPen) + if not has_fill and not has_stroke: + return [] + + color_str = FslConverter.color_to_fsl( + layer.color(), context + ) + size = FslConverter.convert_to_pixels(layer.size(), layer.sizeUnit(), + context) + + stroke_width = FslConverter.convert_stroke_to_pixels( + layer.strokeWidth(), + layer.strokeWidthUnit(), + context + ) + + res = { + 'color': color_str, + 'size': size / 2, # FSL size is radius, not diameter + 'strokeColor': FslConverter.color_to_fsl(layer.strokeColor(), + context) if has_stroke + else FslConverter.NULL_COLOR, + 'strokeWidth': stroke_width + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + # not supported: + # - marker shape + # - offset + # - rotation + + return [res] + + @staticmethod + def ellipse_marker_to_fsl( + layer: QgsEllipseSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS ellipse marker symbol layer to FSL + """ + has_fill = layer.color().isValid() and layer.color().alphaF() > 0 + has_stroke = (layer.strokeColor().alphaF() > 0 and + layer.strokeStyle() != Qt.NoPen) + if not has_fill and not has_stroke: + return [] + + color_str = FslConverter.color_to_fsl( + layer.color(), context + ) + size = max(FslConverter.convert_to_pixels(layer.symbolHeight(), + layer.symbolHeightUnit(), + context), + FslConverter.convert_to_pixels(layer.symbolWidth(), + layer.symbolWidthUnit(), + context)) + + stroke_width = FslConverter.convert_stroke_to_pixels( + layer.strokeWidth(), + layer.strokeWidthUnit(), + context + ) + + res = { + 'color': color_str, + 'size': size / 2, # FSL size is radius, not diameter + 'strokeColor': FslConverter.color_to_fsl(layer.strokeColor(), + context) + if has_stroke else FslConverter.NULL_COLOR, + 'strokeWidth': stroke_width + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + # not supported: + # - marker shape + # - offset + # - rotation + + return [res] + + @staticmethod + def svg_marker_to_fsl( + layer: QgsSvgMarkerSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS SVG marker symbol layer to FSL. Simplistic color/size + conversion only. + """ + has_fill = (layer.fillColor().isValid() and + layer.fillColor().alphaF() > 0) + has_stroke = layer.strokeColor().alphaF() > 0 + if not has_fill and not has_stroke: + return [] + + context.push_warning( + 'SVG markers are not supported, converting to a solid marker') + + color_str = FslConverter.color_to_fsl( + layer.fillColor(), context + ) + size = FslConverter.convert_to_pixels(layer.size(), layer.sizeUnit(), + context) + + stroke_width = FslConverter.convert_stroke_to_pixels( + layer.strokeWidth(), + layer.strokeWidthUnit(), + context + ) + + res = { + 'color': color_str, + 'size': size / 2, # FSL size is radius, not diameter + 'strokeColor': FslConverter.color_to_fsl(layer.strokeColor(), + context) + if has_stroke else FslConverter.NULL_COLOR, + 'strokeWidth': stroke_width + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + # not supported: + # - SVG graphic + # - offset + # - rotation + + return [res] + + @staticmethod + def font_marker_to_fsl( + layer: QgsFontMarkerSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS font marker symbol layer to FSL. Simplistic color/size + conversion only. + """ + has_fill = layer.color().isValid() and layer.color().alphaF() > 0 + has_stroke = layer.strokeColor().alphaF() > 0 + if not has_fill and not has_stroke: + return [] + + context.push_warning( + 'Font markers are not supported, converting to a solid marker') + + color_str = FslConverter.color_to_fsl( + layer.color(), context + ) + size = FslConverter.convert_to_pixels(layer.size(), layer.sizeUnit(), + context) + + stroke_width = FslConverter.convert_stroke_to_pixels( + layer.strokeWidth(), + layer.strokeWidthUnit(), + context + ) + + res = { + 'color': color_str, + 'size': size / 2, # FSL size is radius, not diameter + 'strokeColor': FslConverter.color_to_fsl(layer.strokeColor(), + context) + if has_stroke else FslConverter.NULL_COLOR, + 'strokeWidth': stroke_width + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + # not supported: + # - font graphic + # - offset + # - rotation + + return [res] + + @staticmethod + def filled_marker_to_fsl( + layer: QgsFontMarkerSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS filled marker symbol layer to FSL. Simplistic + color/size conversion only. + """ + converted_subsymbol = FslConverter.symbol_to_fsl(layer.subSymbol(), + context) + if not converted_subsymbol: + return [] + + context.push_warning( + 'Filled markers are not supported, converting to a solid marker') + results = [] + for converted_layer in converted_subsymbol: + color_str = converted_layer.get('color') + stroke_color_str = converted_layer.get('strokeColor') + stroke_width = converted_layer.get('strokeWidth') + + size = FslConverter.convert_to_pixels(layer.size(), + layer.sizeUnit(), context) + + res = { + 'size': size / 2, # FSL size is radius, not diameter + 'color': color_str or FslConverter.NULL_COLOR, + 'strokeColor': stroke_color_str or FslConverter.NULL_COLOR, + } + if stroke_width is not None: + res['strokeWidth'] = stroke_width + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + # not supported: + # - marker shape + # - offset + # - rotation + + return results + + @staticmethod + def shapeburst_fill_to_fsl( + layer: QgsShapeburstFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS shapeburst fill symbol layer to FSL + """ + color = (layer.color() if (layer.color().isValid() and + layer.color().alphaF() > 0) + else layer.color2()) + if not color.isValid() or color.alphaF() == 0: + return [] + + context.push_warning( + 'Shapeburst fills are not supported, converting to a solid fill') + + color_str = FslConverter.color_to_fsl( + color, context + ) + + res = { + 'color': color_str, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + res['strokeColor'] = FslConverter.NULL_COLOR + + return [res] + + @staticmethod + def gradient_fill_to_fsl( + layer: QgsGradientFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS gradient fill symbol layer to FSL + """ + color1_valid = layer.color().isValid() and layer.color().alphaF() > 0 + color = layer.color() if color1_valid else layer.color2() + if not color.isValid() or color.alphaF() == 0: + return [] + + context.push_warning( + 'Gradient fills are not supported, converting to a solid fill') + + color_str = FslConverter.color_to_fsl( + color, context + ) + + res = { + 'color': color_str, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + res['strokeColor'] = FslConverter.NULL_COLOR + + return [res] + + @staticmethod + def line_pattern_fill_to_fsl( + layer: QgsLinePatternFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS line pattern fill symbol layer to FSL + """ + line_symbol = layer.subSymbol() + if line_symbol is None: + return [] + + converted_line = FslConverter.symbol_to_fsl(line_symbol, context) + if not converted_line: + return [] + + # very basic conversion, best we can do is take the color of + # the line fill... + color = layer.subSymbol().color() + if not color.isValid() or color.alphaF() == 0: + return [] + + context.push_warning( + 'Line pattern fills are not supported, converting to a solid fill') + + color_str = FslConverter.color_to_fsl( + color, context + ) + + res = { + 'color': color_str, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + res['strokeColor'] = FslConverter.NULL_COLOR + + return [res] + + @staticmethod + def point_pattern_fill_to_fsl( + layer: QgsPointPatternFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS point pattern fill symbol layer to FSL + """ + marker_symbol = layer.subSymbol() + if marker_symbol is None: + return [] + + converted_marker = FslConverter.symbol_to_fsl(marker_symbol, context) + if not converted_marker: + return [] + + context.push_warning( + 'Point pattern fills are not supported, converting to a solid fill' + ) + results = [] + for converted_layer in converted_marker: + color_str = converted_layer.get('color') + if not color_str: + continue + + res = { + 'color': color_str, + 'strokeColor': FslConverter.NULL_COLOR, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def centroid_fill_to_fsl( + layer: QgsCentroidFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS centroid fill symbol layer to FSL + """ + marker_symbol = layer.subSymbol() + if marker_symbol is None: + return [] + + converted_marker = FslConverter.symbol_to_fsl(marker_symbol, context) + if not converted_marker: + return [] + + context.push_warning( + 'Centroid fills are not supported, converting to a solid fill') + + results = [] + for converted_layer in converted_marker: + color_str = converted_layer.get('color') + if not color_str: + continue + + res = { + 'color': color_str, + 'strokeColor': FslConverter.NULL_COLOR, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def random_marker_fill_to_fsl( + layer: QgsRandomMarkerFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS random marker fill symbol layer to FSL + """ + marker_symbol = layer.subSymbol() + if marker_symbol is None: + return [] + + converted_marker = FslConverter.symbol_to_fsl(marker_symbol, context) + if not converted_marker: + return [] + + context.push_warning( + 'Random marker fills are not supported, converting to a solid fill' + ) + + results = [] + for converted_layer in converted_marker: + color_str = converted_layer.get('color') + if not color_str: + continue + + res = { + 'color': color_str, + 'strokeColor': FslConverter.NULL_COLOR, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + results.append(res) + + return results + + @staticmethod + def svg_fill_to_fsl( + layer: QgsSVGFillSymbolLayer, + context: ConversionContext, + symbol_opacity: float = 1) -> List[Dict[str, object]]: + """ + Converts a QGIS SVG fill symbol layer to FSL + """ + # very basic conversion, best we can do is take the color of the fill + color = layer.svgFillColor() + if not color.isValid() or color.alphaF() == 0: + return [] + + context.push_warning( + 'SVG fills are not supported, converting to a solid fill') + + color_str = FslConverter.color_to_fsl( + color, context + ) + + res = { + 'color': color_str, + } + + if symbol_opacity < 1: + res['opacity'] = symbol_opacity + + res['strokeColor'] = FslConverter.NULL_COLOR + + return [res] + + @staticmethod + def label_settings_to_fsl(settings: QgsPalLayerSettings, + context: ConversionContext) \ + -> Optional[Dict[str, object]]: + """ + Converts label settings to FSL + """ + if not settings.drawLabels or not settings.fieldName: + return None + + if settings.isExpression: + context.push_warning('Expression based labels are not supported', + LogLevel.Warning) + return None + + converted_format = FslConverter.text_format_to_fsl( + settings.format(), context + ) + if settings.autoWrapLength > 0: + converted_format['maxLineChars'] = settings.autoWrapLength + if settings.scaleVisibility: + converted_format[ + 'minZoom'] = MapUtils.map_scale_to_leaflet_tile_zoom( + settings.minimumScale) + converted_format[ + 'maxZoom'] = MapUtils.map_scale_to_leaflet_tile_zoom( + settings.maximumScale) + else: + # these are mandatory! + converted_format['minZoom'] = 1 + converted_format['maxZoom'] = 24 + + res = { + 'config': { + 'labelAttribute': [settings.fieldName] + }, + 'label': converted_format + } + + # For now, we don't convert these and leave them to the Felt + # defaults -- there's too many other unsupported placement + # related configuration settings in QGIS which impact on the + # actual placement of labels in QGIS, we are likely to get an + # inferior result if we force an offset/fixed placement in Felt + # to just the corresponding values from the QGIS layer... + # - offset + # - placement + + return res + + @staticmethod + def text_format_to_fsl(text_format: QgsTextFormat, + context: ConversionContext) \ + -> Dict[str, object]: + """ + Converts a QGIS text format to FSL + """ + res = { + 'color': FslConverter.color_to_fsl( + text_format.color(), context, + opacity=text_format.opacity()), + 'fontSize': FslConverter.convert_to_pixels( + text_format.size(), text_format.sizeUnit(), + context + ), + 'fontStyle': 'italic' if text_format.font().italic() else 'normal', + 'fontWeight': 700 if text_format.font().bold() else 400, + 'haloColor': FslConverter.color_to_fsl( + text_format.buffer().color(), context, + text_format.buffer().opacity() + ) if text_format.buffer().enabled() else FslConverter.NULL_COLOR, + 'haloWidth': FslConverter.convert_to_pixels( + text_format.buffer().size(), + text_format.buffer().sizeUnit(), + context + ) + } + + # letterSpacing + absolute_spacing = FslConverter.convert_to_pixels( + text_format.font().letterSpacing(), + QgsUnitTypes.RenderPoints, context + ) + res['letterSpacing'] = round(absolute_spacing / res['fontSize'], 2) + + # line height conversion + try: + if text_format.lineHeightUnit() == QgsUnitTypes.RenderPercentage: + res['lineHeight'] = round(text_format.lineHeight(), 2) + else: + # absolute line height, convert to relative to font size + line_height_pixels = FslConverter.convert_to_pixels( + text_format.lineHeight(), + text_format.lineHeightUnit(), + context) + res['lineHeight'] = round( + line_height_pixels / res['fontSize'], 2) + except AttributeError: + # QGIS < 3.28, don't convert line height + pass + + if text_format.capitalization() == QgsStringUtils.AllUppercase: + res['textTransform'] = 'uppercase' + elif text_format.capitalization() == QgsStringUtils.AllLowercase: + res['textTransform'] = 'lowercase' + elif text_format.capitalization() != QgsStringUtils.MixedCase: + try: + context.push_warning( + 'Text transform {} is not supported'.format( + text_format.capitalization().name)) + except AttributeError: + context.push_warning( + 'Text transform option {} is not supported'.format( + text_format.capitalization())) + + return res + + @staticmethod + def raster_layer_to_fsl( + layer: QgsRasterLayer, + context: ConversionContext + ) -> Optional[Dict[str, object]]: + """ + Converts a raster layer to FSL + """ + fsl = FslConverter.raster_renderer_to_fsl( + layer.renderer(), context, layer.opacity() + ) + if not fsl: + return None + + is_early_resampling = (layer.resamplingStage() == + QgsRasterPipe.ResamplingStage.Provider) + if (is_early_resampling and + (layer.dataProvider().zoomedInResamplingMethod() != + QgsRasterDataProvider.ResamplingMethod.Nearest or + layer.dataProvider().zoomedOutResamplingMethod() != + QgsRasterDataProvider.ResamplingMethod.Nearest)): + fsl['config']['rasterResampling'] = "linear" + else: + fsl['config']['rasterResampling'] = "nearest" + + if layer.hasScaleBasedVisibility(): + if layer.minimumScale(): + fsl['style']['minZoom'] = ( + MapUtils.map_scale_to_leaflet_tile_zoom( + layer.minimumScale())) + if layer.maximumScale(): + fsl['style']['maxZoom'] = ( + MapUtils.map_scale_to_leaflet_tile_zoom( + layer.maximumScale())) + + return fsl + + @staticmethod + def raster_renderer_to_fsl( + renderer: QgsRasterRenderer, + context: ConversionContext, + opacity: float = 1 + ) -> Optional[Dict[str, object]]: + """ + Converts a raster renderer to FSL + """ + if isinstance(renderer, QgsSingleBandPseudoColorRenderer): + return FslConverter.singleband_pseudocolor_renderer_to_fsl( + renderer, context, opacity + ) + if isinstance(renderer, QgsSingleBandGrayRenderer): + return FslConverter.singleband_gray_renderer_to_fsl( + renderer, context, opacity + ) + if isinstance(renderer, QgsPalettedRasterRenderer): + return FslConverter.paletted_renderer_to_fsl( + renderer, context, opacity + ) + + return None + + @staticmethod + def singleband_pseudocolor_renderer_to_fsl( + renderer: QgsSingleBandPseudoColorRenderer, + context: ConversionContext, + opacity: float = 1 + ) -> Optional[Dict[str, object]]: + """ + Converts a singleband pseudocolor renderer to FSL + """ + + shader = renderer.shader() + shader_function = shader.rasterShaderFunction() + steps = [shader_function.minimumValue()] + colors = [] + labels = {} + for i, item in enumerate(shader_function.colorRampItemList()): + if math.isinf(item.value): + steps.append(shader_function.maximumValue()) + else: + steps.append(item.value) + colors.append(item.color.name()) + labels[str(i)] = item.label + return { + "config": { + "band": renderer.band(), + "steps": steps + }, + "legend": { + "displayName": labels + }, + + "style": { + "isSandwiched": False, + "opacity": opacity, + "color": colors + }, + "type": "numeric" + } + + @staticmethod + def singleband_gray_renderer_to_fsl( + renderer: QgsSingleBandGrayRenderer, + context: ConversionContext, + opacity: float = 1 + ) -> Optional[Dict[str, object]]: + """ + Converts a singleband gray renderer to FSL + """ + steps = [renderer.contrastEnhancement().minimumValue(), + renderer.contrastEnhancement().maximumValue()] + if (renderer.gradient() == + QgsSingleBandGrayRenderer.Gradient.BlackToWhite): + colors = ["rgb(0, 0, 0)", "rgb(255, 255, 255)"] + else: + colors = ["rgb(255, 255, 255)", "rgb(0, 0, 0)"] + + return { + "config": { + "band": renderer.grayBand(), + "steps": steps + }, + "legend": { + "displayName": {"0": str(steps[0]), + "1": str(steps[1])} + }, + + "style": { + "isSandwiched": False, + "opacity": opacity, + "color": colors + }, + "type": "numeric" + } + + @staticmethod + def paletted_renderer_to_fsl( + renderer: QgsPalettedRasterRenderer, + context: ConversionContext, + opacity: float = 1 + ) -> Optional[Dict[str, object]]: + """ + Converts a paletted raster renderer to FSL + """ + + categories = [] + colors = [] + labels = {} + for _class in renderer.classes(): + categories.append(str(_class.value)) + colors.append(_class.color.name()) + labels[str(_class.value)] = _class.label + return { + "config": { + "band": renderer.band(), + "categories": categories, + "categoricalAttribute": "temp" # short term workaround + }, + "legend": { + "displayName": labels + }, + + "style": { + "isSandwiched": False, + "opacity": opacity, + "color": colors + }, + "type": "categorical" + } diff --git a/felt/core/layer_exporter.py b/felt/core/layer_exporter.py index 2cf05c5..f718fb5 100644 --- a/felt/core/layer_exporter.py +++ b/felt/core/layer_exporter.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Layer exporter for Felt - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Layer exporter for Felt +""" import json import math @@ -48,10 +37,6 @@ QgsRasterLayer, QgsRasterFileWriter, QgsRasterBlockFeedback, - QgsSingleSymbolRenderer, - QgsCategorizedSymbolRenderer, - QgsGraduatedSymbolRenderer, - QgsRuleBasedRenderer, QgsSymbol, QgsSimpleFillSymbolLayer, QgsSimpleLineSymbolLayer, @@ -72,6 +57,10 @@ from .layer_style import LayerStyle from .logger import Logger from .map import Map +from .fsl_converter import ( + FslConverter, + ConversionContext +) @dataclass @@ -200,38 +189,58 @@ def import_from_url(layer: QgsMapLayer, target_map: Map, return json.loads(reply.content().data().decode()) @staticmethod - def representative_layer_style(layer: QgsVectorLayer) -> LayerStyle: + def merge_dicts(tgt: Dict, enhancer: Dict) -> Dict: + """ + Recursively merges two dictionaries + """ + for key, val in enhancer.items(): + if key not in tgt: + tgt[key] = val + continue + + if isinstance(val, dict): + LayerExporter.merge_dicts(tgt[key], val) + else: + tgt[key] = val + return tgt + + @staticmethod + def representative_layer_style( + layer: QgsMapLayer) -> LayerStyle: """ Returns a decent representative style for a layer """ if not layer.isSpatial() or not layer.renderer(): return LayerStyle() - if isinstance(layer.renderer(), QgsSingleSymbolRenderer): - return LayerExporter.symbol_to_layer_style( - layer.renderer().symbol() - ) - if isinstance(layer.renderer(), QgsCategorizedSymbolRenderer) and \ - layer.renderer().categories(): - first_category = layer.renderer().categories()[0] - return LayerExporter.symbol_to_layer_style( - first_category.symbol() + context = ConversionContext() + fsl = None + if isinstance(layer, QgsVectorLayer): + fsl = FslConverter.vector_layer_to_fsl( + layer, context ) - if isinstance(layer.renderer(), QgsGraduatedSymbolRenderer) and \ - layer.renderer().ranges(): - first_range = layer.renderer().ranges()[0] - return LayerExporter.symbol_to_layer_style( - first_range.symbol() + if layer.labelsEnabled(): + label_def = FslConverter.label_settings_to_fsl( + layer.labeling().settings(), + context + ) + if label_def: + if fsl: + LayerExporter.merge_dicts(fsl, label_def) + else: + fsl = label_def + + elif isinstance(layer, QgsRasterLayer): + fsl = FslConverter.raster_layer_to_fsl( + layer, context ) - if isinstance(layer.renderer(), QgsRuleBasedRenderer) and \ - layer.renderer().rootRule().children(): - for child in layer.renderer().rootRule().children(): - if child.symbol(): - return LayerExporter.symbol_to_layer_style( - child.symbol() - ) - return LayerStyle() + if fsl: + fsl['version'] = '2.1.1' + + return LayerStyle( + fsl=fsl + ) @staticmethod def symbol_to_layer_style(symbol: QgsSymbol) -> LayerStyle: @@ -271,7 +280,7 @@ def export_layer_for_felt( self, layer: QgsMapLayer, feedback: Optional[QgsFeedback] = None, - upload_raster_as_styled: bool = True + force_upload_raster_as_styled: bool = False ) -> ZippedExportResult: """ Exports a layer into a format acceptable for Felt @@ -281,8 +290,7 @@ def export_layer_for_felt( res = self.export_vector_layer(layer, feedback) elif isinstance(layer, QgsRasterLayer): res = self.export_raster_layer( - layer, feedback, - upload_raster_as_styled) + layer, feedback, force_upload_raster_as_styled) else: assert False @@ -511,12 +519,15 @@ def export_raster_layer( self, layer: QgsRasterLayer, feedback: Optional[QgsFeedback] = None, - upload_raster_as_styled: bool = True) -> LayerExportDetails: + force_upload_raster_as_styled: bool = False) -> LayerExportDetails: """ Exports a raster layer into a format acceptable for Felt """ dest_file = self.generate_file_name('.tif') + converted_style = self.representative_layer_style(layer) + upload_raster_as_styled = (force_upload_raster_as_styled or + not converted_style.fsl) layer_export_result, error_message = self.run_raster_writer( layer, file_name=dest_file, @@ -528,7 +539,7 @@ def export_raster_layer( { 'type': Logger.PACKAGING_RASTER, 'error': 'Error packaging layer: {}'.format(error_message) - } + } ) raise LayerPackagingException(error_message) @@ -537,5 +548,6 @@ def export_raster_layer( filenames=[dest_file], result=layer_export_result, error_message=error_message, - qgis_style_xml=self._get_original_style_xml(layer) + qgis_style_xml=self._get_original_style_xml(layer), + style=converted_style ) diff --git a/felt/core/layer_style.py b/felt/core/layer_style.py index b85f608..b7969fe 100644 --- a/felt/core/layer_style.py +++ b/felt/core/layer_style.py @@ -1,20 +1,12 @@ -# -*- coding: utf-8 -*- -"""Layer exporter for Felt - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Layer exporter for Felt +""" from dataclasses import dataclass -from typing import Optional +from typing import ( + Optional, + Dict +) from qgis.PyQt.QtGui import QColor @@ -26,3 +18,4 @@ class LayerStyle: """ fill_color: Optional[QColor] = None stroke_color: Optional[QColor] = None + fsl: Optional[Dict[str, object]] = None diff --git a/felt/core/logger.py b/felt/core/logger.py index e42b7b7..cdb5061 100644 --- a/felt/core/logger.py +++ b/felt/core/logger.py @@ -1,18 +1,7 @@ """ Logger class - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '14/08/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - import json import os diff --git a/felt/core/map.py b/felt/core/map.py index 4fa73c9..cdf4053 100644 --- a/felt/core/map.py +++ b/felt/core/map.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt API Map - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt API Map +""" import json from dataclasses import dataclass diff --git a/felt/core/map_uploader.py b/felt/core/map_uploader.py index 199d147..8b3d335 100644 --- a/felt/core/map_uploader.py +++ b/felt/core/map_uploader.py @@ -1,18 +1,8 @@ -# -*- coding: utf-8 -*- -"""Felt API Map - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Felt API Map Uploader """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - +import json import re from collections import defaultdict from pathlib import Path @@ -26,7 +16,8 @@ QDate, pyqtSignal, QThread, - QSize + QSize, + QEventLoop ) from qgis.PyQt.QtNetwork import ( QNetworkReply, @@ -43,11 +34,12 @@ QgsFeedback, QgsBlockingNetworkRequest, QgsReferencedRectangle, - QgsSettings + QgsRasterLayer ) from qgis.utils import iface from .api_client import API_CLIENT +from .enums import LayerSupport from .exceptions import LayerPackagingException from .layer_exporter import LayerExporter from .logger import Logger @@ -55,7 +47,6 @@ from .map_utils import MapUtils from .multi_step_feedback import MultiStepFeedback from .s3_upload_parameters import S3UploadParameters -from .enums import LayerSupport class MapUploaderTask(QgsTask): @@ -85,7 +76,7 @@ def __init__(self, project.transformContext() ) self.layers = [ - layer.clone() for layer in layers + MapUploaderTask.clone_layer(layer) for layer in layers ] self.unsupported_layer_details = {} else: @@ -105,7 +96,8 @@ def __init__(self, ] self.layers = [ - layer.clone() for layer in visible_layers if + MapUploaderTask.clone_layer(layer) for layer in visible_layers + if LayerExporter.can_export_layer(layer)[ 0] == LayerSupport.Supported ] @@ -147,6 +139,23 @@ def __init__(self, self.feedback: Optional[QgsFeedback] = None self.was_canceled = False + @staticmethod + def clone_layer(layer: QgsMapLayer) -> QgsMapLayer: + """ + Clones a layer + """ + res = layer.clone() + if isinstance(layer, QgsRasterLayer): + res.setResamplingStage(layer.resamplingStage()) + res.dataProvider().setZoomedInResamplingMethod( + layer.dataProvider().zoomedInResamplingMethod() + ) + res.dataProvider().setZoomedOutResamplingMethod( + layer.dataProvider().zoomedOutResamplingMethod() + ) + + return res + def set_workspace_id(self, workspace_id: Optional[str]): """ Sets the target workspace ID @@ -254,10 +263,6 @@ def run(self): self.feedback = QgsFeedback() - upload_raster_as_styled = QgsSettings().value( - "felt/upload_raster_as_styled", True, bool, QgsSettings.Plugins - ) - multi_step_feedback = MultiStepFeedback( total_steps, self.feedback ) @@ -350,8 +355,7 @@ def run(self): try: result = exporter.export_layer_for_felt( layer, - multi_step_feedback, - upload_raster_as_styled=upload_raster_as_styled + multi_step_feedback ) except LayerPackagingException as e: layer.moveToThread(None) @@ -372,6 +376,8 @@ def run(self): if self.isCanceled(): return False + rate_limit_counter = 0 + for layer, details in to_upload.items(): if self.isCanceled(): return False @@ -381,16 +387,29 @@ def run(self): ) while True: - reply = API_CLIENT.prepare_layer_upload( + reply = API_CLIENT.prepare_layer_upload_v2( map_id=self.associated_map.id, name=layer.name(), - file_names=[Path(details.filename).name], - style=details.style, - blocking=True, feedback=self.feedback ) + if reply.attribute( QNetworkRequest.HttpStatusCodeAttribute) == 429: + rate_limit_counter += 1 + if rate_limit_counter > 3: + self.error_string = \ + 'Rate limit exceeded, cannot share map' + Logger.instance().log_error_json( + { + 'type': Logger.MAP_EXPORT, + 'error': + 'Error preparing layer upload: {}'.format( + self.error_string) + } + ) + + return False + self.status_changed.emit( self.tr('Rate throttled -- waiting') ) @@ -413,9 +432,14 @@ def run(self): if self.isCanceled(): return False + upload_details = json.loads(reply.content().data().decode()) upload_params = S3UploadParameters.from_json( - reply.content().data().decode() - ) + upload_details) + + # unused in api v2? + # file_names = [Path(details.filename).name], + # style = details.style, + if not upload_params.url: self.error_string = self.tr('Could not prepare layer upload') message = "Error retrieving upload parameters: {}".format( @@ -470,39 +494,27 @@ def _upload_progress(sent, total): if self.isCanceled(): return False - self.status_changed.emit( - self.tr('Finalizing {}').format(layer.name()) - ) - - while True: - reply = API_CLIENT.finalize_layer_upload( - self.associated_map.id, - upload_params.layer_id, - Path(details.filename).name, - blocking=True, - feedback=self.feedback - ) - if reply.attribute( - QNetworkRequest.HttpStatusCodeAttribute) == 429: - self.status_changed.emit( - self.tr('Rate throttled -- waiting') - ) - QThread.sleep(5) - continue - - if reply.error() != QNetworkReply.NoError: - self.error_string = reply.errorString() + layer_id = upload_details.get('data', {}).get('attributes', + {}).get( + 'first_layer_id') + if details.style and details.style.fsl is not None: + if not layer_id: Logger.instance().log_error_json( { - 'type': Logger.MAP_EXPORT, - 'error': - 'Error finalizing layer upload: {}'.format( - self.error_string) + 'type': Logger.S3_UPLOAD, + 'error': 'Didn\'t get layer id ' + 'to use for patching style' } ) - return False - - break + else: + reply = API_CLIENT.patch_style( + map_id=self.associated_map.id, + layer_id=layer_id, + fsl=details.style.fsl + ) + loop = QEventLoop() + reply.finished.connect(loop.exit) + loop.exec() multi_step_feedback.step_finished() diff --git a/felt/core/map_utils.py b/felt/core/map_utils.py index 8d8a0f4..1c92f9b 100644 --- a/felt/core/map_utils.py +++ b/felt/core/map_utils.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Map utilities - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2018 by Nyall Dawson' -__date__ = '20/04/2018' -__copyright__ = 'Copyright 2018, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Map utilities +""" from qgis.PyQt.QtCore import ( QSize @@ -54,6 +43,23 @@ class MapUtils: 70.5310735 ] + @staticmethod + def map_scale_to_leaflet_tile_zoom( + scale: float + ) -> int: + """ + Returns the leaflet tile zoom level roughly + corresponding to a QGIS map scale + """ + for level, min_scale in enumerate(MapUtils.ZOOM_LEVEL_SCALE_BREAKS): + if min_scale < scale: + # we play it safe and zoom out a step -- this is because + # we don't know the screen size or DPI on which the map + # will actually be viewed, so we err on the conservative side + return level - 1 + + return len(MapUtils.ZOOM_LEVEL_SCALE_BREAKS) - 1 + @staticmethod def calculate_leaflet_tile_zoom_for_extent( extent: QgsReferencedRectangle, @@ -74,12 +80,4 @@ def calculate_leaflet_tile_zoom_for_extent( map_settings.setOutputSize(target_map_size) scale = map_settings.scale() - - for level, min_scale in enumerate(MapUtils.ZOOM_LEVEL_SCALE_BREAKS): - if min_scale < scale: - # we play it safe and zoom out a step -- this is because - # we don't know the screen size or DPI on which the map - # will actually be viewed, so we err on the conservative side - return level - 1 - - return len(MapUtils.ZOOM_LEVEL_SCALE_BREAKS) - 1 + return MapUtils.map_scale_to_leaflet_tile_zoom(scale) diff --git a/felt/core/meta.py b/felt/core/meta.py index 9618be6..ff0e47a 100644 --- a/felt/core/meta.py +++ b/felt/core/meta.py @@ -1,10 +1,5 @@ -# -*- coding: utf-8 -*- -"""Felt API client - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Plugin metadata parser """ import configparser diff --git a/felt/core/multi_step_feedback.py b/felt/core/multi_step_feedback.py index 3f049eb..9131d38 100644 --- a/felt/core/multi_step_feedback.py +++ b/felt/core/multi_step_feedback.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Multi-step feedback proxy - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Multi-step feedback proxy +""" from qgis.PyQt.QtCore import ( diff --git a/felt/core/pkce.py b/felt/core/pkce.py index b198bd3..fda0f68 100644 --- a/felt/core/pkce.py +++ b/felt/core/pkce.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Simple module to generate PKCE code verifier and code challenge. @@ -10,19 +9,8 @@ >>> import pkce >>> code_verifier = pkce.generate_code_verifier(length=128) >>> code_challenge = pkce.get_code_challenge(code_verifier) - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - import secrets import hashlib diff --git a/felt/core/recent_projects_model.py b/felt/core/recent_projects_model.py index c111d9f..f56f6b3 100644 --- a/felt/core/recent_projects_model.py +++ b/felt/core/recent_projects_model.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Recent maps item model - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '21/08/2023' -__copyright__ = 'Copyright 2023, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Recent maps item model +""" import json import math diff --git a/felt/core/s3_upload_parameters.py b/felt/core/s3_upload_parameters.py index 73bcffe..d5bf15f 100644 --- a/felt/core/s3_upload_parameters.py +++ b/felt/core/s3_upload_parameters.py @@ -1,19 +1,7 @@ -# -*- coding: utf-8 -*- -"""Felt API s3 upload parameters - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Felt API s3 upload parameters """ -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' - -import json from dataclasses import dataclass from typing import ( Optional, @@ -56,11 +44,10 @@ def to_form_fields(self) -> Dict: } @staticmethod - def from_json(jsons: str) -> 'S3UploadParameters': + def from_json(res: str) -> 'S3UploadParameters': """ Creates upload parameters from a JSON string """ - res = json.loads(jsons) return S3UploadParameters( type=res.get('data', {}).get('type'), aws_access_key_id=res.get('data', {}).get('attributes', {}).get( diff --git a/felt/core/thumbnail_manager.py b/felt/core/thumbnail_manager.py index e837e63..1df6c71 100644 --- a/felt/core/thumbnail_manager.py +++ b/felt/core/thumbnail_manager.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Async thumbnail download manager - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '21/08/2023' -__copyright__ = 'Copyright 2023, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Async thumbnail download manager +""" from functools import partial diff --git a/felt/core/user.py b/felt/core/user.py index 0b36054..9381b2c 100644 --- a/felt/core/user.py +++ b/felt/core/user.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt API User - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt API User +""" import json from dataclasses import dataclass diff --git a/felt/core/workspace.py b/felt/core/workspace.py index 005f422..6a90f41 100644 --- a/felt/core/workspace.py +++ b/felt/core/workspace.py @@ -1,9 +1,5 @@ -"""Felt API Workspace - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Felt API Workspace """ import json diff --git a/felt/core/workspaces_model.py b/felt/core/workspaces_model.py index bb65900..765c773 100644 --- a/felt/core/workspaces_model.py +++ b/felt/core/workspaces_model.py @@ -1,9 +1,5 @@ -"""Workspaces item model - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Workspaces item model """ import json diff --git a/felt/gui/authorization_manager.py b/felt/gui/authorization_manager.py index 61e7b6a..651399a 100644 --- a/felt/gui/authorization_manager.py +++ b/felt/gui/authorization_manager.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt Authorization Manager - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt Authorization Manager +""" import platform from functools import partial diff --git a/felt/gui/authorize_dialog.py b/felt/gui/authorize_dialog.py index 58af026..e11a976 100644 --- a/felt/gui/authorize_dialog.py +++ b/felt/gui/authorize_dialog.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt Authorization dialog - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt Authorization dialog +""" from typing import Optional diff --git a/felt/gui/colored_progress_bar.py b/felt/gui/colored_progress_bar.py index ddf8e56..5ff01cb 100644 --- a/felt/gui/colored_progress_bar.py +++ b/felt/gui/colored_progress_bar.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""A colored progress bar - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +A colored progress bar +""" from qgis.PyQt.QtCore import ( Qt diff --git a/felt/gui/create_map_dialog.py b/felt/gui/create_map_dialog.py index 20ff9c1..e5251bc 100644 --- a/felt/gui/create_map_dialog.py +++ b/felt/gui/create_map_dialog.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt Create Map dialog - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt Create Map dialog +""" from typing import ( Optional, @@ -187,7 +176,9 @@ def upload_raster_as_styled_toggled(): self.upload_raster_as_styled_action.toggled.connect( upload_raster_as_styled_toggled) - self.setting_menu.addAction(self.upload_raster_as_styled_action) + # Hidden, now we default to uploading styled whenever we can't + # convert the layer to FSL + # self.setting_menu.addAction(self.upload_raster_as_styled_action) self.setting_menu.addSeparator() self.logout_action = QAction(self.tr('Log Out'), self.setting_menu) diff --git a/felt/gui/felt_dialog_header.py b/felt/gui/felt_dialog_header.py index b288be9..a657812 100644 --- a/felt/gui/felt_dialog_header.py +++ b/felt/gui/felt_dialog_header.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt Authorization dialog - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '22/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt Authorization dialog +""" from typing import Optional diff --git a/felt/gui/gui_utils.py b/felt/gui/gui_utils.py index d38dbea..cdc0ed7 100644 --- a/felt/gui/gui_utils.py +++ b/felt/gui/gui_utils.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""GUI Utilities - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2018 by Nyall Dawson' -__date__ = '20/04/2018' -__copyright__ = 'Copyright 2018, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +GUI Utilities +""" import math import os diff --git a/felt/gui/recent_maps_list_view.py b/felt/gui/recent_maps_list_view.py index 2c53f83..b5b258b 100644 --- a/felt/gui/recent_maps_list_view.py +++ b/felt/gui/recent_maps_list_view.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Widgets for selection from Recent Maps - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '21/08/2023' -__copyright__ = 'Copyright 2023, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Widgets for selection from Recent Maps +""" import platform from typing import Optional diff --git a/felt/gui/workspaces_combo.py b/felt/gui/workspaces_combo.py index b2ebcd5..a47150f 100644 --- a/felt/gui/workspaces_combo.py +++ b/felt/gui/workspaces_combo.py @@ -1,10 +1,5 @@ -# -*- coding: utf-8 -*- -"""Widgets for selection from Workspaces - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. +""" +Widgets for selection from Workspaces """ from typing import Optional diff --git a/felt/plugin.py b/felt/plugin.py index 11f3e1b..9391420 100644 --- a/felt/plugin.py +++ b/felt/plugin.py @@ -1,17 +1,6 @@ -# -*- coding: utf-8 -*- -"""Felt QGIS plugin - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = '(C) 2023 by Nyall Dawson' -__date__ = '1/06/2023' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt QGIS plugin +""" from functools import partial from typing import ( diff --git a/felt/test/test_api_client.py b/felt/test/test_api_client.py index 1eef631..fbaafb6 100644 --- a/felt/test/test_api_client.py +++ b/felt/test/test_api_client.py @@ -1,20 +1,9 @@ -# coding=utf-8 -"""Felt API client Test. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '23/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt API client Test. +""" import unittest +import json from pathlib import Path from qgis.PyQt.QtCore import ( @@ -214,7 +203,7 @@ def test_create_layer(self): QNetworkReply.NoError) json_params = reply.readAll().data().decode() - params = S3UploadParameters.from_json(json_params) + params = S3UploadParameters.from_json(json.loads(json_params)) self.assertEqual(params.type, 'presigned_upload') self.assertTrue(params.aws_access_key_id, 'presigned_upload') # self.assertTrue(params.acl) diff --git a/felt/test/test_fsl_conversion.py b/felt/test/test_fsl_conversion.py new file mode 100644 index 0000000..9769835 --- /dev/null +++ b/felt/test/test_fsl_conversion.py @@ -0,0 +1,2172 @@ +""" +FSL Conversion tests +""" + +import unittest +from pathlib import Path + +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import ( + QColor, + QFont +) + +from qgis.core import ( + NULL, + Qgis, + QgsSimpleLineSymbolLayer, + QgsSimpleFillSymbolLayer, + QgsUnitTypes, + QgsLineSymbol, + QgsFillSymbol, + QgsShapeburstFillSymbolLayer, + QgsGradientFillSymbolLayer, + QgsLinePatternFillSymbolLayer, + QgsSVGFillSymbolLayer, + QgsSimpleMarkerSymbolLayer, + QgsEllipseSymbolLayer, + QgsSvgMarkerSymbolLayer, + QgsFontMarkerSymbolLayer, + QgsFilledMarkerSymbolLayer, + QgsMarkerSymbol, + QgsPointPatternFillSymbolLayer, + QgsCentroidFillSymbolLayer, + QgsRandomMarkerFillSymbolLayer, + QgsMarkerLineSymbolLayer, + QgsHashedLineSymbolLayer, + QgsArrowSymbolLayer, + QgsNullSymbolRenderer, + QgsSingleSymbolRenderer, + QgsCategorizedSymbolRenderer, + QgsRendererCategory, + QgsGraduatedSymbolRenderer, + QgsRendererRange, + QgsTextFormat, + QgsStringUtils, + QgsPalLayerSettings, + QgsVectorLayer, + QgsRuleBasedRenderer, + QgsSingleBandPseudoColorRenderer, + QgsRasterShader, + QgsColorRampShader, + QgsGradientColorRamp, + QgsPalettedRasterRenderer, + QgsSingleBandGrayRenderer, + QgsContrastEnhancement, + QgsInvertedPolygonRenderer, + QgsVectorLayerSimpleLabeling +) + +from .utilities import get_qgis_app +from ..core import ( + FslConverter, + ConversionContext, + LayerExporter +) + +QGIS_APP = get_qgis_app() + +TEST_DATA_PATH = Path(__file__).parent + + +class FslConversionTest(unittest.TestCase): + """Test FSL conversion.""" + + # pylint: disable=protected-access + + def test_color_conversion(self): + """ + Test color conversion + """ + conversion_context = ConversionContext() + self.assertIsNone( + FslConverter.color_to_fsl( + QColor(), conversion_context + ) + ) + self.assertIsNone( + FslConverter.color_to_fsl( + QColor(), conversion_context, opacity=0.5 + ) + ) + self.assertEqual( + FslConverter.color_to_fsl( + QColor(191, 105, 162), conversion_context, opacity=1 + ), + 'rgb(191, 105, 162)' + ) + self.assertEqual( + FslConverter.color_to_fsl( + QColor(191, 105, 162, 100), conversion_context, opacity=1 + ), + 'rgba(191, 105, 162, 0.39)' + ) + self.assertEqual( + FslConverter.color_to_fsl( + QColor(191, 105, 162), conversion_context, opacity=0.2 + ), + 'rgba(191, 105, 162, 0.2)' + ) + self.assertEqual( + FslConverter.color_to_fsl( + QColor(191, 105, 162, 100), conversion_context, opacity=0.2 + ), + 'rgba(191, 105, 162, 0.08)' + ) + + def test_size_conversion(self): + """ + Test size conversion + """ + conversion_context = ConversionContext() + conversion_context.render_context.setScaleFactor(3.779) + + self.assertEqual(FslConverter.convert_to_pixels( + 50, QgsUnitTypes.RenderPixels, conversion_context + ), 50) + self.assertEqual(FslConverter.convert_to_pixels( + 50, QgsUnitTypes.RenderMillimeters, conversion_context + ), 189) + + def test_symbol_conversion(self): + """ + Test symbol conversion + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + symbol = QgsLineSymbol() + symbol.changeSymbolLayer(0, line) + symbol.setOpacity(0.5) + self.assertEqual( + FslConverter.symbol_to_fsl(symbol, conversion_context), + [{'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': 1}] + ) + + def test_simple_line_to_fsl(self): + """ + Test simple line conversion + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + + # no pen + line.setPenStyle(Qt.NoPen) + self.assertFalse( + FslConverter.simple_line_to_fsl(line, conversion_context) + ) + + # transparent color + line.setPenStyle(Qt.SolidLine) + line.setColor(QColor(0, 255, 0, 0)) + self.assertFalse( + FslConverter.simple_line_to_fsl(line, conversion_context) + ) + + line.setColor(QColor(0, 255, 0)) + line.setWidth(3) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 11, + 'lineCap': 'square', + 'lineJoin': 'bevel', + }] + ) + + line.setWidthUnit(QgsUnitTypes.RenderPixels) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'square', + 'lineJoin': 'bevel', + }] + ) + + line.setPenCapStyle(Qt.FlatCap) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'butt', + 'lineJoin': 'bevel', + }] + ) + + line.setPenCapStyle(Qt.RoundCap) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'bevel', + }] + ) + + line.setPenJoinStyle(Qt.RoundJoin) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'round', + }] + ) + + line.setPenJoinStyle(Qt.MiterJoin) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + }] + ) + + line.setPenJoinStyle(Qt.MiterJoin) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context, + symbol_opacity=0.5), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + 'opacity': 0.5, + }] + ) + + line.setPenStyle(Qt.DashLine) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + 'dashArray': [2.5, 2], + }] + ) + + line.setPenStyle(Qt.DotLine) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + 'dashArray': [0.5, 1.3], + }] + ) + + line.setPenStyle(Qt.DashDotLine) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + 'dashArray': [0.5, 1.3, 2.5, 1.3], + }] + ) + + line.setPenStyle(Qt.DashDotDotLine) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 3, + 'lineCap': 'round', + 'lineJoin': 'miter', + 'dashArray': [0.5, 1.3, 0.5, 1.3, 2.5, 1.3], + }] + ) + + line.setPenStyle(Qt.SolidLine) + line.setUseCustomDashPattern(True) + line.setCustomDashPatternUnit(QgsUnitTypes.RenderPixels) + line.setCustomDashVector([0.5, 1, 1.5, 2]) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'dashArray': [0.5, 1.0, 1.5, 2.0], + 'lineCap': 'round', + 'lineJoin': 'miter', + 'size': 3.0}] + ) + line.setCustomDashPatternUnit(QgsUnitTypes.RenderMillimeters) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'dashArray': [1.8895, 3.779, 5.6685, 7.558], + 'lineCap': 'round', + 'lineJoin': 'miter', + 'size': 3.0}] + ) + line.setUseCustomDashPattern(False) + + # hairline + line.setWidth(0) + self.assertEqual( + FslConverter.simple_line_to_fsl(line, conversion_context), + [{ + 'color': 'rgb(0, 255, 0)', + 'size': 1, + 'lineCap': 'round', + 'lineJoin': 'miter', + }] + ) + + def test_simple_fill_to_fsl(self): + """ + Test simple fill conversion + """ + conversion_context = ConversionContext() + + fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) + + fill.setStrokeStyle(Qt.NoPen) + + # no brush + fill.setBrushStyle(Qt.NoBrush) + self.assertFalse( + FslConverter.simple_fill_to_fsl(fill, conversion_context) + ) + + # transparent color + fill.setBrushStyle(Qt.SolidPattern) + fill.setColor(QColor(0, 255, 0, 0)) + self.assertFalse( + FslConverter.simple_fill_to_fsl(fill, conversion_context) + ) + + # transparent color with stroke + fill.setStrokeStyle(Qt.DashLine) + fill.setStrokeWidth(3) + fill.setStrokeColor(QColor(255, 0, 0)) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgba(0, 255, 0, 0.0)', + 'dashArray': [2.5, 2], + 'lineJoin': 'bevel', + 'strokeColor': 'rgb(255, 0, 0)', + 'strokeWidth': 11}] + ) + fill.setStrokeStyle(Qt.SolidLine) + fill.setStrokeColor(QColor(35, 35, 35)) + + fill.setColor(QColor(0, 255, 0)) + fill.setStrokeWidth(3) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'lineJoin': 'bevel', + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 11}] + ) + + fill.setStrokeWidthUnit(QgsUnitTypes.RenderPixels) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'lineJoin': 'bevel', + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + fill.setPenJoinStyle(Qt.RoundJoin) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'lineJoin': 'round', + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + fill.setPenJoinStyle(Qt.MiterJoin) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'lineJoin': 'miter', + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + fill.setPenJoinStyle(Qt.MiterJoin) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(0, 255, 0)', + 'lineJoin': 'miter', + 'opacity': 0.5, + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + fill.setStrokeStyle(Qt.DashLine) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'dashArray': [2.5, 2], + 'lineJoin': 'miter', + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + self.assertEqual( + FslConverter.simple_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(0, 255, 0)', + 'dashArray': [2.5, 2], + 'lineJoin': 'miter', + 'strokeColor': 'rgb(35, 35, 35)', + 'opacity': 0.5, + 'strokeWidth': 3.0}] + ) + + def test_shapeburst_fill_to_fsl(self): + """ + Test shapeburst fill conversion + """ + conversion_context = ConversionContext() + + fill = QgsShapeburstFillSymbolLayer(color=QColor(), + color2=QColor()) + + # no color + self.assertFalse( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context) + ) + fill.setColor(QColor(0, 255, 0, 0)) + self.assertFalse( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context) + ) + + fill.setColor(QColor(0, 255, 0)) + self.assertEqual( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + fill.setColor(QColor(0, 255, 0, 0)) + fill.setColor2(QColor(255, 255, 0, 0)) + self.assertFalse( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context) + ) + + fill.setColor2(QColor(255, 255, 0)) + self.assertEqual( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(255, 255, 0)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.shapeburst_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(255, 255, 0)', + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_gradient_fill_to_fsl(self): + """ + Test gradient fill conversion + """ + conversion_context = ConversionContext() + + fill = QgsGradientFillSymbolLayer(color=QColor(), + color2=QColor()) + + # no color + self.assertFalse( + FslConverter.gradient_fill_to_fsl(fill, conversion_context) + ) + fill.setColor(QColor(0, 255, 0, 0)) + self.assertFalse( + FslConverter.gradient_fill_to_fsl(fill, conversion_context) + ) + + fill.setColor(QColor(0, 255, 0)) + self.assertEqual( + FslConverter.gradient_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(0, 255, 0)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + fill.setColor(QColor(0, 255, 0, 0)) + fill.setColor2(QColor(255, 255, 0, 0)) + self.assertFalse( + FslConverter.gradient_fill_to_fsl(fill, conversion_context) + ) + + fill.setColor2(QColor(255, 255, 0)) + self.assertEqual( + FslConverter.gradient_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(255, 255, 0)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.gradient_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(255, 255, 0)', + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_line_pattern_fill_to_fsl(self): + """ + Test line pattern fill conversion + """ + conversion_context = ConversionContext() + + fill = QgsLinePatternFillSymbolLayer() + # invisible line + line = QgsLineSymbol() + simple_line = QgsSimpleLineSymbolLayer() + simple_line.setPenStyle(Qt.NoPen) + line.changeSymbolLayer(0, simple_line.clone()) + fill.setSubSymbol(line.clone()) + self.assertFalse( + FslConverter.line_pattern_fill_to_fsl(fill, conversion_context) + ) + + # invisible line color + simple_line = QgsSimpleLineSymbolLayer() + simple_line.setColor(QColor(255, 0, 0, 0)) + line.changeSymbolLayer(0, simple_line.clone()) + fill.setSubSymbol(line.clone()) + self.assertFalse( + FslConverter.line_pattern_fill_to_fsl(fill, conversion_context) + ) + + # line with color + simple_line.setColor(QColor(255, 0, 255)) + line.changeSymbolLayer(0, simple_line.clone()) + fill.setSubSymbol(line.clone()) + self.assertEqual( + FslConverter.line_pattern_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(255, 0, 255)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.line_pattern_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(255, 0, 255)', + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_svg_fill_to_fsl(self): + """ + Test SVG fill conversion + """ + conversion_context = ConversionContext() + + fill = QgsSVGFillSymbolLayer('my.svg') + # invisible fill + fill.setSvgFillColor(QColor(255, 0, 0, 0)) + self.assertFalse( + FslConverter.svg_fill_to_fsl(fill, conversion_context) + ) + + # with color + fill.setSvgFillColor(QColor(255, 0, 255)) + self.assertEqual( + FslConverter.svg_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(255, 0, 255)', 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.svg_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(255, 0, 255)', + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_simple_marker_to_fsl(self): + """ + Test simple marker conversion + """ + conversion_context = ConversionContext() + + marker = QgsSimpleMarkerSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + self.assertFalse( + FslConverter.simple_marker_to_fsl(marker, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + self.assertFalse( + FslConverter.simple_marker_to_fsl(marker, conversion_context) + ) + + # with fill, no stroke + marker.setSize(5) + marker.setColor(QColor(120, 130, 140)) + + self.assertEqual( + FslConverter.simple_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + self.assertEqual( + FslConverter.simple_marker_to_fsl(marker, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + # with stroke + marker.setStrokeStyle(Qt.SolidLine) + marker.setStrokeColor(QColor(255, 100, 0)) + marker.setStrokeWidth(2) + marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.simple_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + # size unit + marker.setSizeUnit(QgsUnitTypes.RenderInches) + marker.setSize(0.5) + self.assertEqual( + FslConverter.simple_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 24, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + def test_ellipse_marker_to_fsl(self): + """ + Test ellipse marker conversion + """ + conversion_context = ConversionContext() + + marker = QgsEllipseSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + self.assertFalse( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + self.assertFalse( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context) + ) + + # with fill, no stroke + marker.setSize(5) + marker.setColor(QColor(120, 130, 140)) + + self.assertEqual( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + self.assertEqual( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + # with stroke + marker.setStrokeStyle(Qt.SolidLine) + marker.setStrokeColor(QColor(255, 100, 0)) + marker.setStrokeWidth(2) + marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + # size unit + marker.setSymbolWidthUnit(QgsUnitTypes.RenderInches) + marker.setSymbolWidth(0.5) + marker.setSymbolHeightUnit(QgsUnitTypes.RenderPoints) + marker.setSymbolHeight(1.5) + self.assertEqual( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 24, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + marker.setSymbolHeight(51.5) + self.assertEqual( + FslConverter.ellipse_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 34.5, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + def test_svg_marker_to_fsl(self): + """ + Test SVG marker conversion + """ + conversion_context = ConversionContext() + + marker = QgsSvgMarkerSymbolLayer('my.svg') + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + self.assertFalse( + FslConverter.svg_marker_to_fsl(marker, conversion_context) + ) + + # with fill, no stroke + marker.setSize(5) + marker.setColor(QColor(120, 130, 140)) + + self.assertEqual( + FslConverter.svg_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1.0}] + ) + + self.assertEqual( + FslConverter.svg_marker_to_fsl(marker, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1.0}] + ) + + # with stroke + marker.setStrokeColor(QColor(255, 100, 0)) + marker.setStrokeWidth(2) + marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.svg_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + # size unit + marker.setSizeUnit(QgsUnitTypes.RenderInches) + marker.setSize(0.5) + self.assertEqual( + FslConverter.svg_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 24, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + def test_font_marker_to_fsl(self): + """ + Test font marker conversion + """ + conversion_context = ConversionContext() + + marker = QgsFontMarkerSymbolLayer('my font', 'A') + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + self.assertFalse( + FslConverter.font_marker_to_fsl(marker, conversion_context) + ) + + # with fill, no stroke + marker.setSize(5) + marker.setColor(QColor(120, 130, 140)) + + self.assertEqual( + FslConverter.font_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + self.assertEqual( + FslConverter.font_marker_to_fsl(marker, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'opacity': 0.5, + 'strokeColor': 'rgba(0, 0, 0, 0)', + 'strokeWidth': 1}] + ) + + # with stroke + marker.setStrokeColor(QColor(255, 100, 0)) + marker.setStrokeWidth(2) + marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.font_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 9.5, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + # size unit + marker.setSizeUnit(QgsUnitTypes.RenderInches) + marker.setSize(0.5) + self.assertEqual( + FslConverter.font_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 24, + 'strokeColor': 'rgb(255, 100, 0)', + 'strokeWidth': 3}] + ) + + def test_filled_marker(self): + """ + Test filled marker conversion + """ + conversion_context = ConversionContext() + + fill_symbol = QgsFillSymbol() + fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) + + # no brush, no stroke + fill.setBrushStyle(Qt.NoBrush) + fill.setStrokeStyle(Qt.NoPen) + fill_symbol.changeSymbolLayer(0, fill.clone()) + marker = QgsFilledMarkerSymbolLayer() + marker.setSubSymbol(fill_symbol.clone()) + self.assertFalse( + FslConverter.filled_marker_to_fsl(marker, conversion_context) + ) + + # transparent color + fill.setBrushStyle(Qt.SolidPattern) + fill.setColor(QColor(0, 255, 0, 0)) + fill_symbol.changeSymbolLayer(0, fill.clone()) + marker.setSubSymbol(fill_symbol.clone()) + self.assertFalse( + FslConverter.filled_marker_to_fsl(marker, conversion_context) + ) + + fill.setColor(QColor(0, 255, 0)) + fill.setStrokeWidth(3) + fill_symbol.changeSymbolLayer(0, fill.clone()) + marker.setSubSymbol(fill_symbol.clone()) + self.assertEqual( + FslConverter.filled_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'size': 4, + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + fill.setStrokeStyle(Qt.SolidLine) + fill_symbol.changeSymbolLayer(0, fill.clone()) + marker.setSubSymbol(fill_symbol.clone()) + self.assertEqual( + FslConverter.filled_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'size': 4, + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 11}] + ) + + fill.setStrokeWidthUnit(QgsUnitTypes.RenderPixels) + fill_symbol.changeSymbolLayer(0, fill.clone()) + marker.setSubSymbol(fill_symbol.clone()) + self.assertEqual( + FslConverter.filled_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'size': 4, + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + self.assertEqual( + FslConverter.filled_marker_to_fsl(marker, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(0, 255, 0)', + 'size': 4, + 'strokeColor': 'rgb(35, 35, 35)', + 'opacity': 0.5, + 'strokeWidth': 3.0}] + ) + + marker.setSize(3) + marker.setSizeUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.filled_marker_to_fsl(marker, conversion_context), + [{'color': 'rgb(0, 255, 0)', + 'size': 2, + 'strokeColor': 'rgb(35, 35, 35)', + 'strokeWidth': 3.0}] + ) + + def test_point_pattern_fill_to_fsl(self): + """ + Test point pattern fill conversion + """ + conversion_context = ConversionContext() + + marker = QgsSimpleMarkerSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + + marker_symbol = QgsMarkerSymbol() + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill = QgsPointPatternFillSymbolLayer() + fill.setSubSymbol(marker_symbol.clone()) + + self.assertFalse( + FslConverter.point_pattern_fill_to_fsl(fill, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + self.assertFalse( + FslConverter.point_pattern_fill_to_fsl(fill, conversion_context) + ) + + # with fill, no stroke + marker.setColor(QColor(120, 130, 140)) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + + self.assertEqual( + FslConverter.point_pattern_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.point_pattern_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'opacity': 0.5, 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_centroid_fill_to_fsl(self): + """ + Test centroid fill conversion + """ + conversion_context = ConversionContext() + + marker = QgsSimpleMarkerSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + + marker_symbol = QgsMarkerSymbol() + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill = QgsCentroidFillSymbolLayer() + fill.setSubSymbol(marker_symbol.clone()) + + self.assertFalse( + FslConverter.centroid_fill_to_fsl(fill, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + self.assertFalse( + FslConverter.centroid_fill_to_fsl(fill, conversion_context) + ) + + # with fill, no stroke + marker.setColor(QColor(120, 130, 140)) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + + self.assertEqual( + FslConverter.centroid_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.centroid_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'opacity': 0.5, 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_random_marker_fill_to_fsl(self): + """ + Test random marker fill conversion + """ + conversion_context = ConversionContext() + + marker = QgsSimpleMarkerSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + + marker_symbol = QgsMarkerSymbol() + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill = QgsRandomMarkerFillSymbolLayer() + fill.setSubSymbol(marker_symbol.clone()) + + self.assertFalse( + FslConverter.random_marker_fill_to_fsl(fill, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + self.assertFalse( + FslConverter.random_marker_fill_to_fsl(fill, conversion_context) + ) + + # with fill, no stroke + marker.setColor(QColor(120, 130, 140)) + marker_symbol.changeSymbolLayer(0, marker.clone()) + fill.setSubSymbol(marker_symbol.clone()) + + self.assertEqual( + FslConverter.random_marker_fill_to_fsl(fill, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + self.assertEqual( + FslConverter.random_marker_fill_to_fsl(fill, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'opacity': 0.5, 'strokeColor': 'rgba(0, 0, 0, 0)'}] + ) + + def test_marker_line_to_fsl(self): + """ + Test marker line conversion + """ + conversion_context = ConversionContext() + + marker = QgsSimpleMarkerSymbolLayer() + # invisible fills and strokes + marker.setColor(QColor(255, 0, 0, 0)) + marker.setStrokeColor(QColor(255, 0, 0, 0)) + + marker_symbol = QgsMarkerSymbol() + marker_symbol.changeSymbolLayer(0, marker.clone()) + line = QgsMarkerLineSymbolLayer() + line.setSubSymbol(marker_symbol.clone()) + line.setPlacement(QgsMarkerLineSymbolLayer.Vertex) + + self.assertFalse( + FslConverter.marker_line_to_fsl(line, conversion_context) + ) + + marker.setStrokeColor(QColor(255, 0, 255)) + marker.setStrokeStyle(Qt.NoPen) + marker_symbol.changeSymbolLayer(0, marker.clone()) + line.setSubSymbol(marker_symbol.clone()) + self.assertFalse( + FslConverter.marker_line_to_fsl(line, conversion_context) + ) + + # with fill, no stroke + marker.setColor(QColor(120, 130, 140)) + marker_symbol.changeSymbolLayer(0, marker.clone()) + line.setSubSymbol(marker_symbol.clone()) + + self.assertEqual( + FslConverter.marker_line_to_fsl(line, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 2}] + ) + + self.assertEqual( + FslConverter.marker_line_to_fsl(line, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 2, + 'opacity': 0.5}] + ) + + # interval mode + line.setPlacement(QgsMarkerLineSymbolLayer.Interval) + line.setInterval(10) + line.setIntervalUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.marker_line_to_fsl(line, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 8, + 'dashArray': [8.0, 5.0] + }] + ) + + def test_hashed_line_to_fsl(self): + """ + Test hashed line conversion + """ + conversion_context = ConversionContext() + + hatch = QgsSimpleLineSymbolLayer() + # invisible hatch + hatch.setColor(QColor(255, 0, 0, 0)) + + hatch_symbol = QgsLineSymbol() + hatch_symbol.changeSymbolLayer(0, hatch.clone()) + line = QgsHashedLineSymbolLayer() + line.setSubSymbol(hatch_symbol.clone()) + line.setPlacement(QgsHashedLineSymbolLayer.Vertex) + + self.assertFalse( + FslConverter.hashed_line_to_fsl(line, conversion_context) + ) + + hatch.setColor(QColor(255, 0, 255)) + hatch.setPenStyle(Qt.NoPen) + hatch_symbol.changeSymbolLayer(0, hatch.clone()) + line.setSubSymbol(hatch_symbol.clone()) + self.assertFalse( + FslConverter.hashed_line_to_fsl(line, conversion_context) + ) + + # with hatch + hatch.setColor(QColor(120, 130, 140)) + hatch.setPenStyle(Qt.SolidLine) + hatch_symbol.changeSymbolLayer(0, hatch.clone()) + line.setSubSymbol(hatch_symbol.clone()) + + self.assertEqual( + FslConverter.hashed_line_to_fsl(line, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 1}] + ) + + self.assertEqual( + FslConverter.hashed_line_to_fsl(line, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 1, + 'opacity': 0.5}] + ) + + # interval mode + hatch.setWidth(3) + hatch_symbol.changeSymbolLayer(0, hatch.clone()) + line.setSubSymbol(hatch_symbol.clone()) + line.setPlacement(QgsHashedLineSymbolLayer.Interval) + line.setInterval(10) + line.setIntervalUnit(QgsUnitTypes.RenderPoints) + self.assertEqual( + FslConverter.hashed_line_to_fsl(line, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 11, + 'dashArray': [11.0, 2.0] + }] + ) + + def test_arrow_to_fsl(self): + """ + Test arrow conversion + """ + conversion_context = ConversionContext() + + fill = QgsSimpleFillSymbolLayer() + # invisible fill + fill.setColor(QColor(255, 0, 0, 0)) + fill.setStrokeStyle(Qt.NoPen) + + fill_symbol = QgsFillSymbol() + fill_symbol.changeSymbolLayer(0, fill.clone()) + line = QgsArrowSymbolLayer() + line.setSubSymbol(fill_symbol.clone()) + + self.assertFalse( + FslConverter.arrow_to_fsl(line, conversion_context) + ) + + # with fill + fill.setColor(QColor(120, 130, 140)) + fill_symbol.changeSymbolLayer(0, fill.clone()) + line.setSubSymbol(fill_symbol.clone()) + + line.setArrowWidth(5) + line.setArrowWidthUnit(QgsUnitTypes.RenderPoints) + line.setArrowStartWidth(0.3) + line.setArrowStartWidthUnit(QgsUnitTypes.RenderInches) + + self.assertEqual( + FslConverter.arrow_to_fsl(line, conversion_context), + [{'color': 'rgb(120, 130, 140)', + 'size': 18}] + ) + + self.assertEqual( + FslConverter.arrow_to_fsl(line, conversion_context, + symbol_opacity=0.5), + [{'color': 'rgb(120, 130, 140)', + 'size': 18, + 'opacity': 0.5}] + ) + + def test_single_symbol_renderer(self): + """ + Test converting single symbol renderers + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + line_symbol = QgsLineSymbol() + line_symbol.changeSymbolLayer(0, line.clone()) + + renderer = QgsSingleSymbolRenderer(line_symbol.clone()) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple'} + ) + + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context, + layer_opacity=0.5), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': 1}, + 'type': 'simple'} + ) + + # with casing + line_casing = QgsSimpleLineSymbolLayer(color=QColor(255, 255, 0)) + line_casing.setWidth(10) + line_symbol.appendSymbolLayer(line_casing.clone()) + renderer.setSymbol(line_symbol.clone()) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': [{'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 38}], + 'type': 'simple'} + ) + + def test_expression_to_filter(self): + """ + Test QGIS expression to FSL filter conversions + """ + context = ConversionContext() + + # invalid expression + self.assertIsNone( + FslConverter.expression_to_filter('"Cabin Crew" = ', context) + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" = 1', context), + ["Cabin Crew", "in", [1]] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" <> 1', context), + ["Cabin Crew", "ni", [1]] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" > 1', context), + ["Cabin Crew", "gt", 1] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" < 1', context), + ["Cabin Crew", "lt", 1] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" >= 1', context), + ["Cabin Crew", "ge", 1] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" <= 1', context), + ["Cabin Crew", "le", 1] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" IS NULL', context), + ["Cabin Crew", "is", None] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" IS NOT NULL', + context), + ["Cabin Crew", "isnt", None] + ) + + self.assertEqual( + FslConverter.expression_to_filter('"Cabin Crew" IN (1, 2, 3)', + context), + ['Cabin Crew', 'in', [1, 2, 3]] + ) + + self.assertEqual( + FslConverter.expression_to_filter( + '"Cabin Crew" NOT IN (\'a\', \'b\')', context), + ['Cabin Crew', 'ni', ['a', 'b']] + ) + + def test_rule_based_renderer(self): + """ + Test converting rule based renderers + """ + conversion_context = ConversionContext() + + root_rule = QgsRuleBasedRenderer.Rule(None) + renderer = QgsRuleBasedRenderer(root_rule) + # no child rules! + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgba(0, 0, 0, 0)', + 'strokeColor': 'rgba(0, 0, 0, 0)'}, + 'type': 'simple'} + ) + + # no filter rule + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + line_symbol = QgsLineSymbol() + line_symbol.changeSymbolLayer(0, line.clone()) + child_rule = QgsRuleBasedRenderer.Rule(line_symbol.clone()) + root_rule.appendChild(child_rule) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple'} + ) + + # rule with filter + child_rule.setFilterExpression('"Cabin Crew" IN (1, 2, 3)') + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple', + 'filters': ['Cabin Crew', 'in', [1, 2, 3]] + } + ) + + # filter which can't be converted + child_rule.setFilterExpression('$length > 3') + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple'} + ) + + def test_null_symbol_renderer(self): + """ + Test converting null symbol renderers + """ + conversion_context = ConversionContext() + + renderer = QgsNullSymbolRenderer() + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgba(0, 0, 0, 0)', + 'strokeColor': 'rgba(0, 0, 0, 0)'}, + 'type': 'simple'} + ) + + def test_categorized_renderer(self): + """ + Test converting categorized renderers + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + line_symbol = QgsLineSymbol() + line_symbol.changeSymbolLayer(0, line.clone()) + + line.setColor(QColor(255, 255, 0)) + line.setWidth(5) + line_symbol.appendSymbolLayer(line.clone()) + + line_symbol2 = QgsLineSymbol() + line.setColor(QColor(255, 0, 255)) + line.setWidth(6) + line_symbol2.changeSymbolLayer(0, line.clone()) + + line_symbol3 = QgsLineSymbol() + line.setColor(QColor(0, 255, 255)) + line.setWidth(7) + line_symbol3.changeSymbolLayer(0, line.clone()) + + categories = [ + QgsRendererCategory(1, line_symbol.clone(), 'first cat'), + QgsRendererCategory(2, line_symbol2.clone(), 'second cat'), + QgsRendererCategory(3, line_symbol3.clone(), 'third cat'), + ] + + renderer = QgsCategorizedSymbolRenderer('my_field', + categories) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'config': {'categories': ['1', '2', '3'], + 'categoricalAttribute': 'my_field', + 'showOther': False}, + 'legend': {'displayName': {'1': 'first cat', + '2': 'second cat', + '3': 'third cat'}}, + 'style': [{'color': ['rgb(255, 0, 0)', 'rgb(255, 0, 255)', + 'rgb(0, 255, 255)'], + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': [1, 23, 26]}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 19}], + 'type': 'categorical'} + ) + + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context, + layer_opacity=0.5), + {'config': {'categories': ['1', '2', '3'], + 'categoricalAttribute': 'my_field', + 'showOther': False}, + 'legend': {'displayName': {'1': 'first cat', + '2': 'second cat', + '3': 'third cat'}}, + 'style': [{'color': ['rgb(255, 0, 0)', 'rgb(255, 0, 255)', + 'rgb(0, 255, 255)'], + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': [1, 23, 26]}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': 19}], + 'type': 'categorical'} + ) + + # with "all others" + line.setColor(QColor(100, 100, 100)) + line.setWidth(3) + line_symbol3.changeSymbolLayer(0, line.clone()) + categories.append( + QgsRendererCategory(NULL, line_symbol3.clone(), 'all others')) + renderer = QgsCategorizedSymbolRenderer('my_field', + categories) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'config': {'categories': ['1', '2', '3'], + 'categoricalAttribute': 'my_field', + 'showOther': True}, + 'legend': {'displayName': {'1': 'first cat', + '2': 'second cat', + '3': 'third cat', + 'Other': 'all others'}}, + 'style': [{'color': ['rgb(255, 0, 0)', + 'rgb(255, 0, 255)', + 'rgb(0, 255, 255)', + 'rgb(100, 100, 100)'], + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': [1, 23, 26, 11]}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 19}], + 'type': 'categorical'} + ) + + def test_categorized_no_stroke(self): + """ + Test categorized renderer with no stroke + """ + conversion_context = ConversionContext() + + fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) + fill.setStrokeStyle(Qt.NoPen) + fill_symbol = QgsFillSymbol() + fill_symbol.changeSymbolLayer(0, fill.clone()) + + fill_symbol2 = QgsFillSymbol() + fill.setColor(QColor(255, 0, 255)) + fill_symbol2.changeSymbolLayer(0, fill.clone()) + + categories = [ + QgsRendererCategory(1, fill_symbol.clone(), 'first cat'), + QgsRendererCategory(2, fill_symbol2.clone(), 'second cat') + ] + + renderer = QgsCategorizedSymbolRenderer('my_field', + categories) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, + conversion_context), + {'config': {'categories': ['1', '2'], + 'categoricalAttribute': 'my_field', + 'showOther': False}, + 'legend': {'displayName': {'1': 'first cat', + '2': 'second cat'}}, + 'style': [{'color': ['rgb(255, 0, 0)', 'rgb(255, 0, 255)'], + 'strokeColor': 'rgba(0, 0, 0, 0)'}], + 'type': 'categorical'} + ) + + def test_graduated_renderer(self): + """ + Test converting graduated renderers + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + line_symbol = QgsLineSymbol() + line_symbol.changeSymbolLayer(0, line.clone()) + + line.setColor(QColor(255, 255, 0)) + line.setWidth(5) + line_symbol.appendSymbolLayer(line.clone()) + + line_symbol2 = QgsLineSymbol() + line.setColor(QColor(255, 0, 255)) + line.setWidth(6) + line_symbol2.changeSymbolLayer(0, line.clone()) + + line_symbol3 = QgsLineSymbol() + line.setColor(QColor(0, 255, 255)) + line.setWidth(7) + line_symbol3.changeSymbolLayer(0, line.clone()) + + ranges = [ + QgsRendererRange(1, 2, line_symbol.clone(), 'first range'), + QgsRendererRange(2, 3, line_symbol2.clone(), 'second range'), + QgsRendererRange(3, 4, line_symbol3.clone(), 'third range'), + ] + + renderer = QgsGraduatedSymbolRenderer('my_field', + ranges) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'config': {'numericAttribute': 'my_field', + 'steps': [1.0, 2.0, 3.0, 4.0]}, + 'legend': {'displayName': {'0': 'first range', + '1': 'second range', + '2': 'third range'}}, + 'style': [{'color': ['rgb(255, 0, 0)', 'rgb(255, 0, 255)', + 'rgb(0, 255, 255)'], + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': [1, 23, 26]}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 19}], + 'type': 'numeric'} + ) + + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context, + layer_opacity=0.5), + {'config': {'numericAttribute': 'my_field', + 'steps': [1.0, 2.0, 3.0, 4.0]}, + 'legend': {'displayName': {'0': 'first range', + '1': 'second range', + '2': 'third range'}}, + 'style': [{'color': ['rgb(255, 0, 0)', 'rgb(255, 0, 255)', + 'rgb(0, 255, 255)'], + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': [1, 23, 26]}, + {'color': 'rgb(255, 255, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': 19}], + 'type': 'numeric'} + ) + + @unittest.skipIf(Qgis.QGIS_VERSION_INT < 32400, 'QGIS too old') + def test_text_format_conversion(self): + """ + Test converting text formats + """ + context = ConversionContext() + + f = QgsTextFormat() + font = QFont('Arial') + f.setFont(font) + f.setSize(13) + f.setSizeUnit(QgsUnitTypes.RenderPixels) + f.setColor(QColor(255, 0, 0)) + f.setOpacity(0.3) + + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 4, + 'letterSpacing': 0.0, + 'lineHeight': 1.0} + ) + + # with buffer + f.buffer().setEnabled(True) + f.buffer().setColor(QColor(0, 255, 0)) + f.buffer().setOpacity(0.7) + f.buffer().setSize(4) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 255, 0, 0.7)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 1.0} + ) + + # bold + f.buffer().setEnabled(False) + font.setBold(True) + f.setFont(font) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 700, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 1.0} + ) + # italic + font.setBold(False) + font.setItalic(True) + f.setFont(font) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'italic', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 1.0} + ) + + font.setItalic(False) + # letter spacing + font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 5) + f.setFont(font) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.54, + 'lineHeight': 1.0} + ) + + # line height relative + font = QFont() + f.setFont(font) + f.setLineHeight(1.5) + f.setLineHeightUnit(QgsUnitTypes.RenderPercentage) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 1.5} + ) + # line height absolute + f.setLineHeight(15) + f.setLineHeightUnit(QgsUnitTypes.RenderMillimeters) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 4.38} + ) + + # uppercase + f.setCapitalization(QgsStringUtils.AllUppercase) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 4.38, + 'textTransform': 'uppercase'} + ) + + # lowercase + f.setCapitalization(QgsStringUtils.AllLowercase) + self.assertEqual( + FslConverter.text_format_to_fsl(f, context), + {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 15, + 'letterSpacing': 0.0, + 'lineHeight': 4.38, + 'textTransform': 'lowercase'} + ) + + @unittest.skipIf(Qgis.QGIS_VERSION_INT < 32400, 'QGIS too old') + def test_label_settings(self): + """ + Test converting label settings + """ + context = ConversionContext() + + f = QgsTextFormat() + font = QFont('Arial') + f.setFont(font) + f.setSize(13) + f.setSizeUnit(QgsUnitTypes.RenderPixels) + f.setColor(QColor(255, 0, 0)) + f.setOpacity(0.3) + + label_settings = QgsPalLayerSettings() + label_settings.setFormat(f) + + # no labels + label_settings.drawLabels = False + self.assertIsNone( + FslConverter.label_settings_to_fsl(label_settings, context) + ) + label_settings.drawLabels = True + label_settings.fieldName = '' + self.assertIsNone( + FslConverter.label_settings_to_fsl(label_settings, context) + ) + + # expression labels, unsupported + label_settings.fieldName = '1 + 2' + label_settings.isExpression = True + self.assertIsNone( + FslConverter.label_settings_to_fsl(label_settings, context) + ) + + # simple field + label_settings.fieldName = 'my_field' + label_settings.isExpression = False + self.assertEqual( + FslConverter.label_settings_to_fsl(label_settings, context), + {'config': {'labelAttribute': ['my_field']}, + 'label': {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 4, + 'letterSpacing': 0.0, + 'lineHeight': 1.0, + 'maxZoom': 24, + 'minZoom': 1}} + ) + + # with line wrap + label_settings.autoWrapLength = 15 + self.assertEqual( + FslConverter.label_settings_to_fsl(label_settings, context), + {'config': {'labelAttribute': ['my_field']}, + 'label': {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 4, + 'letterSpacing': 0.0, + 'lineHeight': 1.0, + 'maxLineChars': 15, + 'maxZoom': 24, + 'minZoom': 1}} + ) + label_settings.autoWrapLength = 0 + + # zoom ranges + label_settings.scaleVisibility = True + label_settings.minimumScale = 5677474 + label_settings.maximumScale = 34512 + self.assertEqual( + FslConverter.label_settings_to_fsl(label_settings, context), + {'config': {'labelAttribute': ['my_field']}, + 'label': {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 4, + 'letterSpacing': 0.0, + 'lineHeight': 1.0, + 'maxZoom': 14, + 'minZoom': 6}} + ) + + def test_layer_to_fsl(self): + """ + Test converting whole layer to FSL + """ + conversion_context = ConversionContext() + + line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) + line_symbol = QgsLineSymbol() + line_symbol.changeSymbolLayer(0, line.clone()) + + renderer = QgsSingleSymbolRenderer(line_symbol.clone()) + self.assertEqual( + FslConverter.vector_renderer_to_fsl(renderer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple'} + ) + layer = QgsVectorLayer('x', '', 'memory') + layer.setRenderer(renderer) + + self.assertEqual( + FslConverter.vector_layer_to_fsl(layer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'size': 1}, + 'type': 'simple'} + ) + + # layer opacity + layer.setOpacity(0.5) + self.assertEqual( + FslConverter.vector_layer_to_fsl(layer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'opacity': 0.5, + 'size': 1}, + 'type': 'simple'} + ) + layer.setOpacity(1) + + # zoom range + layer.setScaleBasedVisibility(True) + layer.setMinimumScale(10000) + self.assertEqual( + FslConverter.vector_layer_to_fsl(layer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'minZoom': 15, + 'size': 1}, + 'type': 'simple'} + ) + layer.setMaximumScale(1000) + self.assertEqual( + FslConverter.vector_layer_to_fsl(layer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'minZoom': 15, + 'maxZoom': 19, + 'size': 1}, + 'type': 'simple'} + ) + layer.setMinimumScale(0) + self.assertEqual( + FslConverter.vector_layer_to_fsl(layer, conversion_context), + {'legend': {}, + 'style': {'color': 'rgb(255, 0, 0)', + 'lineCap': 'square', + 'lineJoin': 'bevel', + 'maxZoom': 19, + 'size': 1}, + 'type': 'simple'} + ) + + @unittest.skipIf(Qgis.QGIS_VERSION_INT < 32400, 'QGIS too old') + def test_layer_to_fsl_with_labels_no_renderer(self): + """ + Test conversion to Fsl when labels can be converted but not + renderer + """ + f = QgsTextFormat() + font = QFont('Arial') + f.setFont(font) + f.setSize(13) + f.setSizeUnit(QgsUnitTypes.RenderPixels) + f.setColor(QColor(255, 0, 0)) + f.setOpacity(0.3) + + label_settings = QgsPalLayerSettings() + label_settings.setFormat(f) + label_settings.fieldName = 'my_field' + label_settings.isExpression = False + + layer = QgsVectorLayer('Polygon', '', 'memory') + renderer = QgsInvertedPolygonRenderer() + layer.setRenderer(renderer) + + layer.setLabelsEnabled(True) + layer.setLabeling(QgsVectorLayerSimpleLabeling(label_settings)) + + style = LayerExporter.representative_layer_style(layer) + + self.assertEqual( + style.fsl, + {'config': {'labelAttribute': ['my_field']}, + 'label': {'color': 'rgba(255, 0, 0, 0.3)', + 'fontSize': 13, + 'fontStyle': 'normal', + 'fontWeight': 400, + 'haloColor': 'rgba(0, 0, 0, 0)', + 'haloWidth': 4, + 'letterSpacing': 0.0, + 'lineHeight': 1.0, + 'maxZoom': 24, + 'minZoom': 1}, + 'version': '2.1.1'} + ) + + def test_convert_singleband_pseudocolor(self): + """ + Convert singleband pseudocolor renderer + """ + context = ConversionContext() + gradient = QgsGradientColorRamp( + QColor(255, 0, 0), + QColor(0, 255, 0) + ) + color_ramp_shader = QgsColorRampShader(50, 200) + color_ramp_shader.setSourceColorRamp( + gradient.clone() + ) + color_ramp_shader.setColorRampItemList( + [ + QgsColorRampShader.ColorRampItem( + 120, QColor(0, 255, 0), 'lowest' + ), + QgsColorRampShader.ColorRampItem( + 125, QColor(255, 255, 0), 'mid' + ), + QgsColorRampShader.ColorRampItem( + 130, QColor(0, 255, 255), 'highest' + ) + ] + ) + renderer = QgsSingleBandPseudoColorRenderer(None, + band=1) + shader = QgsRasterShader() + shader.setRasterShaderFunction(QgsColorRampShader(color_ramp_shader)) + renderer.setShader(shader) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 1, + 'steps': [50.0, 120.0, 125.0, 130.0]}, + 'legend': { + 'displayName': {'0': 'lowest', '1': 'mid', '2': 'highest'}}, + 'style': {'color': ['#00ff00', '#ffff00', '#00ffff'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'numeric'} + ) + + renderer = QgsSingleBandPseudoColorRenderer(None, + band=2) + shader = QgsRasterShader() + shader.setRasterShaderFunction(QgsColorRampShader(color_ramp_shader)) + renderer.setShader(shader) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 2, + 'steps': [50.0, 120.0, 125.0, 130.0]}, + 'legend': { + 'displayName': {'0': 'lowest', '1': 'mid', '2': 'highest'}}, + 'style': {'color': ['#00ff00', '#ffff00', '#00ffff'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'numeric'} + ) + + def test_convert_singleband_gray_renderer(self): + """ + Convert singleband gray renderer + """ + context = ConversionContext() + renderer = QgsSingleBandGrayRenderer(None, 1) + enhancement = QgsContrastEnhancement() + enhancement.setMinimumValue(5) + enhancement.setMaximumValue(5) + renderer.setContrastEnhancement(QgsContrastEnhancement(enhancement)) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 1, 'steps': [5.0, 5.0]}, + 'legend': {'displayName': {'0': '5.0', '1': '5.0'}}, + 'style': {'color': ['rgb(0, 0, 0)', 'rgb(255, 255, 255)'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'numeric'} + ) + + renderer = QgsSingleBandGrayRenderer(None, 2) + renderer.setGradient(QgsSingleBandGrayRenderer.Gradient.WhiteToBlack) + renderer.setContrastEnhancement(QgsContrastEnhancement(enhancement)) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 2, 'steps': [5.0, 5.0]}, + 'legend': {'displayName': {'0': '5.0', '1': '5.0'}}, + 'style': {'color': ['rgb(255, 255, 255)', 'rgb(0, 0, 0)'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'numeric'} + ) + + @unittest.skip('Broken API, disabled for now') + def test_convert_paletted_raster(self): + """ + Convert raster paletted renderer + """ + context = ConversionContext() + class_data = [ + QgsPalettedRasterRenderer.Class( + 120, QColor(0, 255, 0), 'lowest' + ), + QgsPalettedRasterRenderer.Class( + 125, QColor(255, 255, 0), 'mid' + ), + QgsPalettedRasterRenderer.Class( + 130, QColor(0, 255, 255), 'highest' + ) + ] + renderer = QgsPalettedRasterRenderer(None, + bandNumber=1, + classes=class_data) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 1, 'categories': ['120.0', '125.0', '130.0']}, + 'legend': {'displayName': {'120.0': 'lowest', + '125.0': 'mid', + '130.0': 'highest'}}, + 'style': {'color': ['#00ff00', '#ffff00', '#00ffff'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'categorical'} + ) + + renderer = QgsPalettedRasterRenderer(None, + bandNumber=2, + classes=class_data) + + self.assertEqual(FslConverter.raster_renderer_to_fsl( + renderer, context), + {'config': {'band': 2, 'categories': ['120.0', '125.0', '130.0']}, + 'legend': {'displayName': {'120.0': 'lowest', + '125.0': 'mid', + '130.0': 'highest'}}, + 'style': {'color': ['#00ff00', '#ffff00', '#00ffff'], + 'isSandwiched': False, + 'opacity': 1}, + 'type': 'categorical'} + ) + + +if __name__ == "__main__": + suite = unittest.makeSuite(FslConversionTest) + runner = unittest.TextTestRunner(verbosity=2) + runner.run(suite) diff --git a/felt/test/test_guiutils.py b/felt/test/test_guiutils.py index 60efe57..d816a14 100644 --- a/felt/test/test_guiutils.py +++ b/felt/test/test_guiutils.py @@ -1,18 +1,6 @@ -# coding=utf-8 -"""GUI Utils Test. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ - -__author__ = '(C) 2018 by Nyall Dawson' -__date__ = '20/04/2018' -__copyright__ = 'Copyright 2018, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +GUI Utils Test. +""" import unittest from ..gui.gui_utils import GuiUtils diff --git a/felt/test/test_init.py b/felt/test/test_init.py index 5e1ac40..ddc2dee 100644 --- a/felt/test/test_init.py +++ b/felt/test/test_init.py @@ -1,17 +1,6 @@ -# coding=utf-8 -"""Tests QGIS plugin init. - -.. note:: This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. """ - -__author__ = 'Nyall Dawson ' -__revision__ = '$Format:%H$' -__date__ = '20/04/2018' -__license__ = "GPL" -__copyright__ = 'Copyright 2018, LINZ' +Tests QGIS plugin init. +""" import os diff --git a/felt/test/test_layer_exporter.py b/felt/test/test_layer_exporter.py index 9e7ad03..f675207 100644 --- a/felt/test/test_layer_exporter.py +++ b/felt/test/test_layer_exporter.py @@ -1,18 +1,6 @@ -# coding=utf-8 -"""Felt API client Test. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ - -__author__ = '(C) 2022 by Nyall Dawson' -__date__ = '23/11/2022' -__copyright__ = 'Copyright 2022, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Felt API client Test. +""" import unittest import zipfile @@ -24,7 +12,9 @@ QgsRasterLayer, QgsCoordinateTransformContext, QgsCoordinateReferenceSystem, - QgsWkbTypes + QgsWkbTypes, + QgsPalettedRasterRenderer, + QgsRasterContourRenderer ) from .utilities import get_qgis_app @@ -160,6 +150,7 @@ def test_layer_style(self): ) # should only be the layer's style, not the source information self.assertNotIn('points.gpkg', style) + # pylint: enable=protected-access def test_vector_conversion(self): @@ -231,12 +222,16 @@ def test_raster_conversion_raw(self): file = str(TEST_DATA_PATH / 'dem.tif') layer = QgsRasterLayer(file, 'test') self.assertTrue(layer.isValid()) + # set a renderer we can convert + renderer = QgsPalettedRasterRenderer(layer.dataProvider(), 1, []) + layer.setRenderer(renderer) exporter = LayerExporter( QgsCoordinateTransformContext() ) - result = exporter.export_layer_for_felt(layer, - upload_raster_as_styled=False) + result = exporter.export_layer_for_felt( + layer + ) self.assertEqual(result.result, LayerExportResult.Success) self.assertTrue(result.filename) self.assertEqual(result.filename[-4:], '.zip') @@ -280,8 +275,67 @@ def test_raster_conversion_styled(self): exporter = LayerExporter( QgsCoordinateTransformContext() ) - result = exporter.export_layer_for_felt(layer, - upload_raster_as_styled=True) + result = exporter.export_layer_for_felt( + layer, + force_upload_raster_as_styled=True + ) + self.assertEqual(result.result, LayerExportResult.Success) + self.assertTrue(result.filename) + self.assertEqual(result.filename[-4:], '.zip') + with zipfile.ZipFile(result.filename) as z: + tif_files = [f for f in z.namelist() if f.endswith('tif')] + self.assertEqual(len(tif_files), 1) + + self.assertEqual( + result.qgis_style_xml[:58], + "" + ) + + out_layer = QgsRasterLayer( + '/vsizip/{}/{}'.format(result.filename, tif_files[0]), + 'test') + self.assertTrue(out_layer.isValid()) + self.assertEqual(out_layer.width(), 373) + self.assertEqual(out_layer.height(), 350) + self.assertEqual(out_layer.bandCount(), 4) + self.assertEqual(out_layer.dataProvider().dataType(1), + Qgis.DataType.Byte) + self.assertEqual(out_layer.dataProvider().dataType(2), + Qgis.DataType.Byte) + self.assertEqual(out_layer.dataProvider().dataType(3), + Qgis.DataType.Byte) + self.assertEqual(out_layer.dataProvider().dataType(4), + Qgis.DataType.Byte) + self.assertEqual(out_layer.crs(), + QgsCoordinateReferenceSystem('EPSG:4326')) + self.assertAlmostEqual(out_layer.extent().xMinimum(), + 18.6662979442, 3) + self.assertAlmostEqual(out_layer.extent().xMaximum(), + 18.7035979442, 3) + self.assertAlmostEqual(out_layer.extent().yMinimum(), + 45.7767014376, 3) + self.assertAlmostEqual(out_layer.extent().yMaximum(), + 45.8117014376, 3) + + def test_raster_conversion_no_fsl_conversion(self): + """ + Test raster layer conversion + """ + file = str(TEST_DATA_PATH / 'dem.tif') + layer = QgsRasterLayer(file, 'test') + self.assertTrue(layer.isValid()) + + # set a renderer we can't convert to FSL + renderer = QgsRasterContourRenderer(layer.dataProvider()) + layer.setRenderer(renderer) + + exporter = LayerExporter( + QgsCoordinateTransformContext() + ) + result = exporter.export_layer_for_felt( + layer, + force_upload_raster_as_styled=False + ) self.assertEqual(result.result, LayerExportResult.Success) self.assertTrue(result.filename) self.assertEqual(result.filename[-4:], '.zip') diff --git a/felt/test/test_maputils.py b/felt/test/test_maputils.py index be2957b..c22c232 100644 --- a/felt/test/test_maputils.py +++ b/felt/test/test_maputils.py @@ -1,18 +1,6 @@ -# coding=utf-8 -"""Map Utils Test. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ - -__author__ = '(C) 2018 by Nyall Dawson' -__date__ = '20/04/2018' -__copyright__ = 'Copyright 2018, North Road' -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = '$Format:%H$' +Map Utils Test. +""" import unittest diff --git a/felt/test/test_qgis_environment.py b/felt/test/test_qgis_environment.py index 9a774c9..c32d475 100644 --- a/felt/test/test_qgis_environment.py +++ b/felt/test/test_qgis_environment.py @@ -1,16 +1,6 @@ -# coding=utf-8 -"""Tests for QGIS functionality. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ -__author__ = 'tim@linfiniti.com' -__date__ = '20/01/2011' -__copyright__ = ('Copyright 2012, Australia Indonesia Facility for ' - 'Disaster Reduction') +Tests for QGIS functionality. +""" import unittest from qgis.core import QgsProviderRegistry diff --git a/felt/test/test_translations.py b/felt/test/test_translations.py index 225fffb..e3840f2 100644 --- a/felt/test/test_translations.py +++ b/felt/test/test_translations.py @@ -1,17 +1,6 @@ -# coding=utf-8 -"""Safe Translations Test. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ - -__author__ = 'ismailsunni@yahoo.co.id' -__date__ = '12/10/2011' -__copyright__ = ('Copyright 2012, Australia Indonesia Facility for ' - 'Disaster Reduction') +Safe Translations Test. +""" import unittest import os diff --git a/felt/test/utilities.py b/felt/test/utilities.py index 7786335..8fbaa88 100644 --- a/felt/test/utilities.py +++ b/felt/test/utilities.py @@ -1,5 +1,6 @@ -# coding=utf-8 -"""Common functionality used by regression tests.""" +""" +Common functionality used by regression tests. +""" import sys import logging diff --git a/felt/test_suite.py b/felt/test_suite.py index d6a10db..cc7828e 100644 --- a/felt/test_suite.py +++ b/felt/test_suite.py @@ -1,12 +1,5 @@ -# coding=utf-8 """ Test Suite. - -.. note:: This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - """ import sys