diff --git a/README.md b/README.md index 02e3d2f..833a2ba 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,15 @@ The structure is exported. But not any additional files. For that, we need to pa ### Create the `ExportSettings`: -Use `QMLSTYLE` for the export of the qml stylefile. -Use `DEFINITION` to export the qlr definition file. -USE `SOURCE` to store the source in the YAML tree. +#### Layer Tree Settings -The QgsLayerTreeNode or the layername can be used as key. +We can decide for every layer (group) if we want to: + +- Use `QMLSTYLE` for the export of the qml stylefile. +- Use `DEFINITION` to export the qlr definition file. +- USE `SOURCE` to store the source in the YAML tree. + +The `QgsLayerTreeNode` or the layername can be used as key. ```py export_settings = ExportSettings() @@ -131,10 +135,47 @@ export_settings.set_setting_values( ) ``` -### Generate the Files for a `ProjectTopping` containing `ExportSetting` -When parsing the QgsProject we need to pass the `ExportSettings`: +Without an additional setting, only the default style is considered. To export the style of multiple style add them as seperate setting entries: + +```py +# default style (if style_name "default" is added, it makes no difference) +export_settings.set_setting_values( + type = ExportSettings.ToppingType.QMLSTYLE, node = None, name = "Street", export = True ) +) +# french style (e.g. french aliases) +export_settings.set_setting_values( + type = ExportSettings.ToppingType.QMLSTYLE, node = None, name = "Street", export = True, categories = category_flags, style_name = "french" ) +) +# robot style (e.g. technical look) +export_settings.set_setting_values( + type = ExportSettings.ToppingType.QMLSTYLE, node = None, name = "Street", export = True, categories = category_flags, style_name = "robot" ) +) ``` +#### Map Themes Settings + +Set the names of the map themes that should be considered as a list: +```py +export_settings.mapthemes = ["Robot Theme", "French Theme"] +``` + +#### Custom Project Variables Settings + +Set the keys of custom variables that should be considered as a list: +```py +export_settings.variables = ["first_variable", "Another_Variable"] +``` + +#### Print Layout Settings + +Set the names of layouts that should be considered (exported as template files) as a list: +```py +export_settings.variables = ["Layout One", "Layout Three"] +``` + +### Generate the Files for a `ProjectTopping` containing `ExportSetting` +When parsing the QgsProject we need to pass the `ExportSettings`: +```py project_topping.parse_project(project, export_settings) project_topping.generate_files(target) ``` @@ -149,47 +190,105 @@ repo └── projecttopping └── freddys_qgis_project.yaml └── layerstyle - └── freddys_qgis_project_street.qml + ├── freddys_qgis_project_street.qml + ├── freddys_qgis_project_street_french.qml + └── freddys_qgis_project_street_robot.qml + └── layouttemplate + ├── freddys_qgis_project_layout_one.qpt + └── freddys_qgis_project_layout_three.qpt ``` And the YAML looks like this: ```yaml -layerorder: [] layertree: -- Street: - tablename: street - geometrycolumn: geometry - checked: true - expanded: true - stylefile: freddys_qgis_topping/layerstyle/freddys_qgis_project_street.qml -- Park: - tablename: park - geometrycolumn: geometry - checked: false - expanded: true - provider: ogr - uri: /home/freddy/qgis_projects/bakery/cityandcity.gpkg|layername=park -- Building: - tablename: building_2 - geometrycolumn: geometry - checked: true - expanded: true -- Info Layers: - checked: true - definitionfile: freddys_qgis_topping/layerdefinition/freddys_qgis_project_info_layers.qlr - expanded: true - group: true -- Background: - checked: true - child-nodes: - expanded: true - group: true - - Landeskarten (grau): - checked: true - expanded: true - provider: wms - uri: contextualWMSLegend=0&crs=EPSG:2056&dpiMode=7&featureCount=10&format=image/jpeg&layers=ch.swisstopo.pixelkarte-grau&styles&url=https://wms.geo.admin.ch/?%0ASERVICE%3DWMS%0A%26VERSION%3D1.3.0%0A%26REQUEST%3DGetCapabilities + - Street: + tablename: street + geometrycolumn: geometry + checked: true + expanded: true + qmlstylefile: freddys_qgis_topping/layerstyle/freddys_qgis_project_street.qml + styles: + french: + qmlstylefile: freddys_qgis_topping/layerstyle/freddys_qgis_project_street_french.qml + robot: + qmlstylefile: freddys_qgis_topping/layerstyle/freddys_qgis_project_street_robot.qml + - Park: + tablename: park + geometrycolumn: geometry + checked: false + expanded: true + provider: ogr + uri: /home/freddy/qgis_projects/bakery/cityandcity.gpkg|layername=park + - Building: + tablename: building_2 + geometrycolumn: geometry + checked: true + expanded: true + - Info Layers: + checked: true + definitionfile: freddys_qgis_topping/layerdefinition/freddys_qgis_project_info_layers.qlr + expanded: true + group: true + - Background: + checked: true + expanded: true + group: true + child-nodes: + - Landeskarten (grau): + checked: true + expanded: true + provider: wms + uri: contextualWMSLegend=0&crs=EPSG:2056&dpiMode=7&featureCount=10&format=image/jpeg&layers=ch.swisstopo.pixelkarte-grau&styles&url=https://wms.geo.admin.ch/?%0ASERVICE%3DWMS%0A%26VERSION%3D1.3.0%0A%26REQUEST%3DGetCapabilities + +mapthemes: + "French Theme": + Street: + style: french + visible: true + expanded: true + Buildings: + style: default + visible: false + expanded: true + "Robot Theme": + Street: + style: robot + expanded_items: + [ + "{f6c29bf7-af28-4d88-8092-ee9568ac731f}", + "{fc48a8d7-d774-46c7-97c7-74ecde13a3ec}", + ] + checked_items: + [ + "{f6c29bf7-af28-4d88-8092-ee9568ac731f}", + "{dc726dd5-d0d7-4275-be02-f6916df4fa29}", + ] + Buildings: + style: default + visible: true + expanded: false + Other_Layers_Group: + group: true + checked: true + expanded: false + Other_Layers_Group/SubGroup: + group: true + checked: true + expanded: false + +layerorder: [] + +variables: + "first_variable": "This is a test value." + "Another_Variable": "2" + +layouts: + "Layout One": + templatefile: "../layouttemplate/freddys_qgis_project_layout_one.qpt" + "Layout Three": + templatefile: "../layouttemplate/freddys_qgis_project_layout_three.qpt" + ``` ## Most important functions @@ -197,10 +296,10 @@ layertree: A project configuration resulting in a YAML file that contains: - layertree - layerorder -- project variables (future) -- print layout (future) -- layer styles (future) -- map themes (future) +- layer styles +- map themes +- project variables +- print layouts QML style files, QLR layer definition files and the source of a layer can be linked in the YAML file and are exported to the specific folders. @@ -250,14 +349,17 @@ A member variable `toppingfileinfo_list = []` is defined, to store all the infor ### exportsettings.ExportSettings +#### Layertree Settings The requested export settings of each node in the specific dicts: - qmlstyle_setting_nodes - definition_setting_nodes - source_setting_nodes + The usual structure is using QgsLayerTreeNode as key and then export True/False ```py +qmlstyle_nodes = { : { export: False } : { export: True } @@ -268,6 +370,7 @@ Alternatively the layername can be used as key. In ProjectTopping it first looks Using the node is much more consistent, since one can use layers with the same name, but for nodes you need the project already in advance. With name you can use prepared settings to pass (before the project exists) e.g. in automated workflows. ```py +qmlstyle_nodes = { "Node1": { export: False } "Node2": { export: True } @@ -283,7 +386,18 @@ qmlstyle_nodes = } ``` -#### `set_setting_values( type: ToppingType, node: Union[QgsLayerTreeLayer, QgsLayerTreeGroup] = None, name: str = None, export=True categories=None, ) -> bool` +If styles are used as well we create tuples as key. Mutable objects are not alowed in it, so they would be created with the (layer) name and the style (name): +```py +qmlstyle_nodes = +{ + : { export: False } + : { export: True, categories: } + ("Node2","french"): { export: True, categories: }, + ("Node2","robot"): { export: True, categories: } +} +``` + +##### `set_setting_values( type: ToppingType, node: Union[QgsLayerTreeLayer, QgsLayerTreeGroup] = None, name: str = None, export=True categories=None, style_name: str = None) -> bool` Set the specific types concerning the enumerations: ```py @@ -293,6 +407,17 @@ class ToppingType(Enum): SOURCE = 3 ``` +#### Map Themes Settings + +The export setting of the map themes is a simple list of maptheme names: `mapthemes = []` + +#### Custom Project Variables: + +The export setting of the custom variables is simple list of the keys stored in `variables = []`. + +#### Layouts: + +The export setting of the print layouts is simple list of the layout names stored in `layouts = []`. ## Infos for Devs diff --git a/tests/test_toppingmaker.py b/tests/test_toppingmaker.py index f7eb337..4beec2d 100644 --- a/tests/test_toppingmaker.py +++ b/tests/test_toppingmaker.py @@ -24,11 +24,19 @@ import tempfile import yaml -from qgis.core import QgsProject, QgsVectorLayer -from qgis.testing import unittest +from qgis.core import ( + QgsExpressionContextUtils, + QgsMapThemeCollection, + QgsPrintLayout, + QgsProject, + QgsVectorLayer, +) +from qgis.testing import start_app, unittest from toppingmaker import ExportSettings, ProjectTopping, Target +start_app() + class ToppingMakerTest(unittest.TestCase): @classmethod @@ -52,6 +60,8 @@ def test_target(self): def test_parse_project(self): """ + Parse it without export settings... + "Big Group": group: True child-nodes: @@ -83,6 +93,7 @@ def test_parse_project(self): project_topping = ProjectTopping() project_topping.parse_project(project) + # check layertree checked_groups = [] for item in project_topping.layertree.items: if item.name == "Big Group": @@ -98,7 +109,64 @@ def test_parse_project(self): checked_groups.append("Small Group") assert checked_groups == ["Big Group", "Medium Group", "Small Group"] + def test_parse_project_with_mapthemes(self): + """ + Parse it with export settings defining map themes, variables and layouts + """ + project, export_settings = self._make_project_and_export_settings() + + project_topping = ProjectTopping() + project_topping.parse_project(project, export_settings) + + # check mapthemes + mapthemes = project_topping.mapthemes + assert mapthemes["Robot Theme"]["Layer One"] + assert mapthemes["Robot Theme"]["Layer One"]["style"] == "robot 1" + assert mapthemes["Robot Theme"]["Layer Three"] + assert mapthemes["Robot Theme"]["Layer Three"]["style"] == "robot 3" + assert mapthemes["Robot Theme"]["Small Group"] + assert mapthemes["Robot Theme"]["Small Group"]["expanded"] + assert mapthemes["Robot Theme"]["Big Group"] + assert mapthemes["Robot Theme"]["Big Group"]["expanded"] + assert "Medium Group" not in mapthemes["Robot Theme"] + + assert set(mapthemes.keys()) == {"French Theme", "Robot Theme"} + assert mapthemes["French Theme"]["Layer One"] + assert mapthemes["French Theme"]["Layer One"]["style"] == "french 1" + assert mapthemes["French Theme"]["Layer Three"] + assert mapthemes["French Theme"]["Layer Three"]["style"] == "french 3" + assert mapthemes["French Theme"]["Medium Group"] + assert mapthemes["French Theme"]["Medium Group"]["expanded"] + assert "Small Group" not in mapthemes["French Theme"] + assert "Big Group" not in mapthemes["French Theme"] + + # check variables + variables = project_topping.variables + # Anyway in practice no spaces should be used to be able to access them in the expressions like @first_variable + assert variables.get("First Variable") == "This is a test value." + # QGIS is currently (3.29) not able to store structures in the project file. Still... + assert variables.get("Variable with Structure") == [ + "Not", + "The", + "Normal", + 815, + "Case", + ] + # "Another Variable" is in the project but not in the export_settings + assert "Another Variable" not in variables + + # check layouts + layouts = project_topping.layouts + assert layouts.get("Layout One") + assert layouts.get("Layout Three") + # "Layout Two" is in the project but not in the export_settings + assert "Layout Two" not in layouts + def test_generate_files(self): + """ + Generate projecttopping file with layertree, map themes, variables and layouts. + And all the toppingfiles for styles, definition and layouttemplates. + """ project, export_settings = self._make_project_and_export_settings() layers = project.layerTreeRoot().findLayers() self.assertEqual(len(layers), 10) @@ -130,7 +198,8 @@ def test_generate_files(self): target.main_dir, project_topping.generate_files(target) ) - # check projecttopping_file + # check layertree projecttopping_file + foundAllofEm = False foundLayerOne = False foundLayerTwo = False @@ -156,6 +225,225 @@ def test_generate_files(self): assert foundLayerOne assert foundLayerTwo + # check mapthemes in projecttopping_file + + foundFrenchTheme = False + foundRobotTheme = False + + with open(projecttopping_file_path, "r") as yamlfile: + projecttopping_data = yaml.safe_load(yamlfile) + assert "mapthemes" in projecttopping_data + assert projecttopping_data["mapthemes"] + for theme_name in projecttopping_data["mapthemes"].keys(): + if theme_name == "Robot Theme": + foundRobotTheme = True + expected_records = { + "Layer One", + "Layer Three", + "Small Group", + "Big Group", + } + assert expected_records == set( + projecttopping_data["mapthemes"]["Robot Theme"].keys() + ) + checked_record_count = 0 + for record_name in projecttopping_data["mapthemes"][ + "Robot Theme" + ].keys(): + if record_name == "Layer One": + assert ( + "style" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer One" + ] + ) + assert ( + projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer One" + ]["style"] + == "robot 1" + ) + assert ( + "visible" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer One" + ] + ) + assert not projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer One" + ]["visible"] + checked_record_count += 1 + if record_name == "Layer Three": + assert ( + "style" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer Three" + ] + ) + assert ( + projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer Three" + ]["style"] + == "robot 3" + ) + assert ( + "visible" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer Three" + ] + ) + assert projecttopping_data["mapthemes"]["Robot Theme"][ + "Layer Three" + ]["visible"] + checked_record_count += 1 + if record_name == "Small Group": + assert ( + "expanded" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Small Group" + ] + ) + assert projecttopping_data["mapthemes"]["Robot Theme"][ + "Small Group" + ]["expanded"] + checked_record_count += 1 + if record_name == "Big Group": + assert ( + "expanded" + in projecttopping_data["mapthemes"]["Robot Theme"][ + "Big Group" + ] + ) + assert projecttopping_data["mapthemes"]["Robot Theme"][ + "Big Group" + ]["expanded"] + checked_record_count += 1 + assert checked_record_count == 4 + if theme_name == "French Theme": + foundFrenchTheme = True + expected_records = {"Layer One", "Layer Three", "Medium Group"} + assert expected_records == set( + projecttopping_data["mapthemes"]["French Theme"].keys() + ) + checked_record_count = 0 + for record_name in projecttopping_data["mapthemes"][ + "French Theme" + ].keys(): + if record_name == "Layer One": + assert ( + "style" + in projecttopping_data["mapthemes"]["French Theme"][ + "Layer One" + ] + ) + assert ( + projecttopping_data["mapthemes"]["French Theme"][ + "Layer One" + ]["style"] + == "french 1" + ) + assert ( + "visible" + in projecttopping_data["mapthemes"]["French Theme"][ + "Layer One" + ] + ) + assert projecttopping_data["mapthemes"]["French Theme"][ + "Layer One" + ]["visible"] + checked_record_count += 1 + if record_name == "Layer Three": + assert ( + "style" + in projecttopping_data["mapthemes"]["French Theme"][ + "Layer Three" + ] + ) + assert ( + projecttopping_data["mapthemes"]["French Theme"][ + "Layer Three" + ]["style"] + == "french 3" + ) + assert ( + "visible" + in projecttopping_data["mapthemes"]["French Theme"][ + "Layer Three" + ] + ) + assert not projecttopping_data["mapthemes"]["French Theme"][ + "Layer Three" + ]["visible"] + checked_record_count += 1 + if record_name == "Medium Group": + assert ( + "expanded" + in projecttopping_data["mapthemes"]["French Theme"][ + "Medium Group" + ] + ) + assert projecttopping_data["mapthemes"]["French Theme"][ + "Medium Group" + ]["expanded"] + checked_record_count += 1 + assert checked_record_count == 3 + + assert foundFrenchTheme + assert foundRobotTheme + + # check variables + variable_count = 0 + foundFirstVariable = False + foundVariableWithStructure = False + + with open(projecttopping_file_path, "r") as yamlfile: + projecttopping_data = yaml.safe_load(yamlfile) + assert "variables" in projecttopping_data + assert projecttopping_data["variables"] + for variable_key in projecttopping_data["variables"].keys(): + if variable_key == "First Variable": + assert ( + projecttopping_data["variables"][variable_key] + == "This is a test value." + ) + foundFirstVariable = True + if variable_key == "Variable with Structure": + assert projecttopping_data["variables"][variable_key] == [ + "Not", + "The", + "Normal", + 815, + "Case", + ] + foundVariableWithStructure = True + variable_count += 1 + + assert variable_count == 2 + assert foundFirstVariable + assert foundVariableWithStructure + + # check layouts + layout_count = 0 + foundLayoutOne = False + foundLayoutThree = False + + with open(projecttopping_file_path, "r") as yamlfile: + projecttopping_data = yaml.safe_load(yamlfile) + assert "layouts" in projecttopping_data + assert projecttopping_data["layouts"] + for layout_name in projecttopping_data["layouts"].keys(): + if layout_name == "Layout One": + assert "templatefile" in projecttopping_data["layouts"][layout_name] + foundLayoutOne = True + if layout_name == "Layout Three": + assert "templatefile" in projecttopping_data["layouts"][layout_name] + foundLayoutThree = True + layout_count += 1 + + assert layout_count == 2 + assert foundLayoutOne + assert foundLayoutThree + # check toppingfiles # there should be exported 6 files (see _make_project_and_export_settings) @@ -171,10 +459,15 @@ def test_generate_files(self): countchecked = 0 - # there should be 13 toppingfiles: one project topping, and 2 x 6 toppingfiles of the layers (since the layers are multiple times in the tree) - assert len(target.toppingfileinfo_list) == 13 + # there should be 21 toppingfiles: + # - one project topping + # - 2 x 3 qlr files (two times since the layers are multiple times in the tree) + # - 2 x 6 qml files (one layers with 3 styles, one layer with 2 styles and one layer with one style - and two times since the layers are multiple times in the tree) + # - 2 qpt template files + assert len(target.toppingfileinfo_list) == 21 for toppingfileinfo in target.toppingfileinfo_list: + self.print_info(toppingfileinfo["path"]) assert "path" in toppingfileinfo assert "type" in toppingfileinfo @@ -183,11 +476,26 @@ def test_generate_files(self): == "freddys_projects/this_specific_project/layerstyle/freddys_layer_one.qml" ): countchecked += 1 + if ( + toppingfileinfo["path"] + == "freddys_projects/this_specific_project/layerstyle/freddys_layer_one_french_1.qml" + ): + countchecked += 1 + if ( + toppingfileinfo["path"] + == "freddys_projects/this_specific_project/layerstyle/freddys_layer_one_robot_1.qml" + ): + countchecked += 1 if ( toppingfileinfo["path"] == "freddys_projects/this_specific_project/layerstyle/freddys_layer_three.qml" ): countchecked += 1 + if ( + toppingfileinfo["path"] + == "freddys_projects/this_specific_project/layerstyle/freddys_layer_three_french_3.qml" + ): + countchecked += 1 if ( toppingfileinfo["path"] == "freddys_projects/this_specific_project/layerstyle/freddys_layer_five.qml" @@ -208,8 +516,19 @@ def test_generate_files(self): == "freddys_projects/this_specific_project/layerdefinition/freddys_layer_five.qlr" ): countchecked += 1 + if ( + toppingfileinfo["path"] + == "freddys_projects/this_specific_project/layouttemplate/freddys_layout_one.qpt" + ): + countchecked += 1 + if ( + toppingfileinfo["path"] + == "freddys_projects/this_specific_project/layouttemplate/freddys_layout_three.qpt" + ): + countchecked += 1 - assert countchecked == 12 + # without the projecttopping file they are 20 + assert countchecked == 20 def test_custom_path_resolver(self): # load QGIS project into structure @@ -259,6 +578,9 @@ def test_custom_path_resolver(self): assert countchecked == 6 def _make_project_and_export_settings(self): + # --- + # make the project + # --- project = QgsProject() project.removeAllMapLayers() @@ -283,6 +605,21 @@ def _make_project_and_export_settings(self): ) assert l5.isValid() + # append style to layer one and three + style_manager = l1.styleManager() + l1.setDisplayExpression("'French:'||'un'") + style_manager.addStyleFromLayer("french 1") + l1.setDisplayExpression("'Robot:'||'0001'") + style_manager.addStyleFromLayer("robot 1") + style_manager.setCurrentStyle("default") + + style_manager = l3.styleManager() + l3.setDisplayExpression("'French:'||'trois'") + style_manager.addStyleFromLayer("french 3") + l3.setDisplayExpression("'Robot:'||'0011'") + style_manager.addStyleFromLayer("robot 3") + style_manager.setCurrentStyle("default") + project.addMapLayer(l1, False) project.addMapLayer(l2, False) project.addMapLayer(l3, False) @@ -306,13 +643,105 @@ def _make_project_and_export_settings(self): allofemgroup.addLayer(l4) allofemgroup.addLayer(l5) + # create robot map theme + # with styles and layer one unchecked + map_theme_record = QgsMapThemeCollection.MapThemeRecord() + map_theme_layer_record = QgsMapThemeCollection.MapThemeLayerRecord() + map_theme_layer_record.setLayer(l1) + map_theme_layer_record.usingCurrentStyle = True + map_theme_layer_record.currentStyle = "robot 1" + map_theme_layer_record.isVisible = False + map_theme_record.addLayerRecord(map_theme_layer_record) + map_theme_layer_record = QgsMapThemeCollection.MapThemeLayerRecord() + map_theme_layer_record.setLayer(l3) + map_theme_layer_record.usingCurrentStyle = True + map_theme_layer_record.currentStyle = "robot 3" + map_theme_layer_record.isVisible = True + map_theme_record.addLayerRecord(map_theme_layer_record) + # group Big and Small expanded, Medium not expanded + map_theme_record.setHasExpandedStateInfo(True) + map_theme_record.setExpandedGroupNodes(["Small Group", "Big Group"]) + project.mapThemeCollection().insert("Robot Theme", map_theme_record) + + # create french map theme + # with styles and layer three unchecked + map_theme_record = QgsMapThemeCollection.MapThemeRecord() + map_theme_layer_record = QgsMapThemeCollection.MapThemeLayerRecord() + map_theme_layer_record.setLayer(l1) + map_theme_layer_record.usingCurrentStyle = True + map_theme_layer_record.currentStyle = "french 1" + map_theme_layer_record.isVisible = True + map_theme_record.addLayerRecord(map_theme_layer_record) + map_theme_layer_record = QgsMapThemeCollection.MapThemeLayerRecord() + map_theme_layer_record.setLayer(l3) + map_theme_layer_record.usingCurrentStyle = True + map_theme_layer_record.currentStyle = "french 3" + map_theme_layer_record.isVisible = False + map_theme_record.addLayerRecord(map_theme_layer_record) + # group Medium expanded, Big and Small not expanded + map_theme_record.setHasExpandedStateInfo(True) + map_theme_record.setExpandedGroupNodes(["Medium Group"]) + project.mapThemeCollection().insert("French Theme", map_theme_record) + + # set the custom project variables + QgsExpressionContextUtils.setProjectVariable( + project, "First Variable", "This is a test value." + ) + QgsExpressionContextUtils.setProjectVariable(project, "Another Variable", "2") + QgsExpressionContextUtils.setProjectVariable( + project, "Variable with Structure", ["Not", "The", "Normal", 815, "Case"] + ) + + # create layouts + layout = QgsPrintLayout(project) + layout.initializeDefaults() + layout.setName("Layout One") + project.layoutManager().addLayout(layout) + layout = QgsPrintLayout(project) + layout.initializeDefaults() + layout.setName("Layout Two") + project.layoutManager().addLayout(layout) + layout = QgsPrintLayout(project) + layout.initializeDefaults() + layout.setName("Layout Three") + project.layoutManager().addLayout(layout) + + # --- + # and make the export settings + # --- export_settings = ExportSettings() export_settings.set_setting_values( ExportSettings.ToppingType.QMLSTYLE, None, "Layer One", True ) + # exporting "french" and "robot" style to layer one + export_settings.set_setting_values( + ExportSettings.ToppingType.QMLSTYLE, + None, + "Layer One", + True, + None, + "french 1", + ) + export_settings.set_setting_values( + ExportSettings.ToppingType.QMLSTYLE, + None, + "Layer One", + True, + None, + "robot 1", + ) + # only exporting "french" style to layer three export_settings.set_setting_values( ExportSettings.ToppingType.QMLSTYLE, None, "Layer Three", True ) + export_settings.set_setting_values( + ExportSettings.ToppingType.QMLSTYLE, + None, + "Layer Three", + True, + None, + "french 3", + ) export_settings.set_setting_values( ExportSettings.ToppingType.QMLSTYLE, None, "Layer Five", True ) @@ -337,9 +766,25 @@ def _make_project_and_export_settings(self): ExportSettings.ToppingType.SOURCE, None, "Layer Three", True ) - print(export_settings.qmlstyle_setting_nodes) - print(export_settings.definition_setting_nodes) - print(export_settings.source_setting_nodes) + # define the map themes to export + export_settings.mapthemes = ["French Theme", "Robot Theme"] + + # define the custom variables to export + export_settings.variables = ["First Variable", "Variable with Structure"] + + # define the layouts to export + export_settings.layouts = ["Layout One", "Layout Three"] + + self.print_info( + f" Layer to style export: {export_settings.qmlstyle_setting_nodes}" + ) + self.print_info( + f" Layer to definition export: {export_settings.definition_setting_nodes}" + ) + self.print_info( + f" Layer to source export: {export_settings.source_setting_nodes}" + ) + self.print_info(f" Map Themes to export: {export_settings.mapthemes}") return project, export_settings def print_info(self, text): diff --git a/toppingmaker/exportsettings.py b/toppingmaker/exportsettings.py index 98c92ab..3cf3280 100644 --- a/toppingmaker/exportsettings.py +++ b/toppingmaker/exportsettings.py @@ -65,6 +65,14 @@ class ExportSettings(object): The map themes to export are a simple list of map theme names stored in `mapthemes`. + # Custom Project Variables: + + The custom variables to export are a simple list of the keys stored in `variables`. + + # Layouts: + + The print layouts to export are a simple list of layout names stored in `layouts`. + """ class ToppingType(Enum): @@ -77,8 +85,12 @@ def __init__(self): self.qmlstyle_setting_nodes = {} self.definition_setting_nodes = {} self.source_setting_nodes = {} - # list of mapthemes to be exported + # names of mapthemes to be exported self.mapthemes = [] + # keys of custom variables to be exported + self.variables = [] + # names of layouts + self.layouts = [] def set_setting_values( self, diff --git a/toppingmaker/projecttopping.py b/toppingmaker/projecttopping.py index 2dd97e4..001c793 100644 --- a/toppingmaker/projecttopping.py +++ b/toppingmaker/projecttopping.py @@ -18,19 +18,23 @@ ***************************************************************************/ """ +import logging import os +import tempfile from typing import Union import yaml from qgis.core import ( Qgis, QgsDataSourceUri, + QgsExpressionContextUtils, QgsLayerDefinition, QgsLayerTreeGroup, QgsLayerTreeLayer, QgsLayerTreeNode, QgsMapLayer, QgsProject, + QgsReadWriteContext, ) from qgis.PyQt.QtCore import QObject, pyqtSignal @@ -44,9 +48,9 @@ class ProjectTopping(QObject): A project configuration resulting in a YAML file that contains: - layertree - layerorder - - project variables (future) - - print layout (future) - - map themes (future) + - map themes + - project variables + - print layouts QML style files, QLR layer definition files and the source of a layer can be linked in the YAML file and are exported to the specific folders. """ @@ -56,12 +60,23 @@ class ProjectTopping(QObject): PROJECTTOPPING_TYPE = "projecttopping" LAYERDEFINITION_TYPE = "layerdefinition" LAYERSTYLE_TYPE = "layerstyle" + LAYOUTTEMPLATE_TYPE = "layouttemplate" class TreeItemProperties(object): """ The properties of a node (tree item) """ + class StyleItemProperties(object): + """ + The properties of a style item of a node style. + Currently it's only a qmlstylefile. Maybe in future here a style can be defined. + """ + + def __init__(self): + # the style file - if None then not requested + self.qmlstylefile = None + def __init__(self): # if the node is a group self.group = False @@ -87,17 +102,21 @@ def __init__(self): self.tablename = None # the geometry column (if no source available) self.geometrycolumn = None + # the styles can contain multiple style items with StyleItemProperties + self.styles = {} class LayerTreeItem(object): """ A tree item of the layer tree. Every item contains the properties of a layer and according the ExportSettings passed on parsing the QGIS project. """ - def __init__(self): + def __init__(self, temporary_toppingfile_dir=None): self.items = [] self.name = None self.properties = ProjectTopping.TreeItemProperties() - self.temporary_toppingfile_dir = os.path.expanduser("~/.temp_topping_files") + self.temporary_toppingfile_dir = temporary_toppingfile_dir + if not self.temporary_toppingfile_dir: + self.temporary_toppingfile_dir = tempfile.mkdtemp() def make_item( self, @@ -125,7 +144,9 @@ def make_item( # only consider children, when the group is not exported as DEFINITION index = 0 for child in node.children(): - item = ProjectTopping.LayerTreeItem() + item = ProjectTopping.LayerTreeItem( + self.temporary_toppingfile_dir + ) item.make_item(project, child, export_settings) # set the first checked item as mutually exclusive child if ( @@ -177,20 +198,55 @@ def make_item( provider.dataSourceUri().split("layername=")[1].strip() ) - qml_setting = export_settings.get_setting( + # get the default style + qml_default_setting = export_settings.get_setting( ExportSettings.ToppingType.QMLSTYLE, node, node.name() + ) or export_settings.get_setting( + ExportSettings.ToppingType.QMLSTYLE, node, node.name(), "default" ) - if qml_setting.get("export", False): + + if qml_default_setting.get("export", False): self.properties.qmlstylefile = self._temporary_qmlstylefile( layer, QgsMapLayer.StyleCategory( - qml_setting.get( + qml_default_setting.get( "categories", QgsMapLayer.StyleCategory.AllStyleCategories, ) ), ) + # get all the other styles + current_style = layer.styleManager().currentStyle() + for style_name in layer.styleManager().styles(): + # we skip the 'default' style because it's handled above + if style_name == "default": + continue + + qml_style_setting = export_settings.get_setting( + ExportSettings.ToppingType.QMLSTYLE, + node, + node.name(), + style_name, + ) + if qml_style_setting.get("export", False): + style_properties = ( + ProjectTopping.TreeItemProperties.StyleItemProperties() + ) + style_properties.qmlstylefile = self._temporary_qmlstylefile( + layer, + QgsMapLayer.StyleCategory( + qml_style_setting.get( + "categories", + QgsMapLayer.StyleCategory.AllStyleCategories, + ) + ), + style_name, + ) + self.properties.styles[style_name] = style_properties + # reset the style of the project layer + layer.styleManager().setCurrentStyle(current_style) + def _layer_of_node( self, project: QgsProject, @@ -208,20 +264,42 @@ def _temporary_definitionfile( temporary_toppingfile_path = os.path.join( self.temporary_toppingfile_dir, filename_slug ) - QgsLayerDefinition.exportLayerDefinition(temporary_toppingfile_path, [node]) + result, result_message = QgsLayerDefinition.exportLayerDefinition( + temporary_toppingfile_path, [node] + ) + if not result: + logging.warning( + "Could not export definitionfile of {} to {}: {}".format( + node.name(), temporary_toppingfile_path, result_message + ) + ) return temporary_toppingfile_path def _temporary_qmlstylefile( self, layer: QgsMapLayer, categories: QgsMapLayer.StyleCategories = QgsMapLayer.StyleCategory.AllStyleCategories, + style_name: str = None, ): - filename_slug = f"{slugify(self.name)}.qml" + filename_slug = f"{slugify(self.name)}{f'_{slugify(style_name)}' if style_name else ''}.qml" os.makedirs(self.temporary_toppingfile_dir, exist_ok=True) temporary_toppingfile_path = os.path.join( self.temporary_toppingfile_dir, filename_slug ) - layer.saveNamedStyle(temporary_toppingfile_path, categories) + if style_name: + layer.styleManager().setCurrentStyle(style_name) + result_message, result = layer.saveNamedStyle( + temporary_toppingfile_path, categories + ) + if not result: + logging.warning( + "Could not export qmlstylefile of {} ({}) to {}: {}".format( + layer.name(), + style_name, + temporary_toppingfile_path, + result_message, + ) + ) return temporary_toppingfile_path def item_dict(self, target: Target): @@ -248,6 +326,16 @@ def item_dict(self, target: Target): item_properties_dict["qmlstylefile"] = target.toppingfile_link( ProjectTopping.LAYERSTYLE_TYPE, self.properties.qmlstylefile ) + if self.properties.styles: + item_properties_dict["styles"] = {} + for style_name in self.properties.styles.keys(): + item_properties_dict["styles"][style_name] = {} + item_properties_dict["styles"][style_name][ + "qmlstylefile" + ] = target.toppingfile_link( + ProjectTopping.LAYERSTYLE_TYPE, + self.properties.styles[style_name].qmlstylefile, + ) if self.properties.provider and self.properties.uri: item_properties_dict["provider"] = self.properties.provider item_properties_dict["uri"] = self.properties.uri @@ -275,10 +363,135 @@ def items_list(self, target: Target): item_list.append(item_dict) return item_list + class MapThemes(dict): + """ + A dict object of dict items describing a MapThemeRecord according to the maptheme names listed in the ExportSettings passed on parsing the QGIS project. + """ + + def make_items( + self, + project: QgsProject, + export_settings: ExportSettings, + ): + self.clear() + + maptheme_collection = project.mapThemeCollection() + for name in export_settings.mapthemes: + maptheme_item = {} + maptheme_record = maptheme_collection.mapThemeState(name) + for layerrecord in maptheme_record.layerRecords(): + layername = layerrecord.layer().name() + maptheme_item[layername] = {} + if layerrecord.usingCurrentStyle: + maptheme_item[layername]["style"] = layerrecord.currentStyle + maptheme_item[layername]["visible"] = layerrecord.isVisible + maptheme_item[layername]["expanded"] = layerrecord.expandedLayerNode + if layerrecord.expandedLegendItems: + maptheme_item[layername][ + "expanded_items" + ] = layerrecord.expandedLegendItems + if layerrecord.usingLegendItems: + maptheme_item[layername][ + "checked_items" + ] = layerrecord.checkedLegendItems + + if maptheme_record.hasExpandedStateInfo(): + for expanded_groupnode in maptheme_record.expandedGroupNodes(): + maptheme_item[expanded_groupnode] = {} + maptheme_item[expanded_groupnode]["expanded"] = True + # setHasCheckedStateInfo(True) is not available in the API, what makes it impossible to control the checked state of a group + # see https://github.com/SebastienPeillet/QGIS/commit/736e46daa640b8a9c66107b4f05319d6d2534ac5#discussion_r1037225879 + for checked_groupnode in maptheme_record.checkedGroupNodes(): + maptheme_item[checked_groupnode] = {} + maptheme_item[checked_groupnode]["checked"] = True + + self[name] = maptheme_item + + class Variables(dict): + """ + A dict object of dict items describing a variable according to the variable keys listed in the ExportSettings passed on parsing the QGIS project. + """ + + def make_items( + self, + project: QgsProject, + export_settings: ExportSettings, + ): + self.clear() + for variable_key in export_settings.variables: + self[variable_key] = QgsExpressionContextUtils.projectScope( + project + ).variable(variable_key) + + class Layouts(dict): + """ + A dict object of dict items describing a layout with templatefile according to the layout names listed in the ExportSettings passed on parsing the QGIS project. + Such a dict item contains only one key at the moment: "templatefile" + """ + + def __init__(self, temporary_toppingfile_dir=None): + self.temporary_toppingfile_dir = temporary_toppingfile_dir + if not self.temporary_toppingfile_dir: + self.temporary_toppingfile_dir = tempfile.mkdtemp() + + def make_items( + self, + project: QgsProject, + export_settings: ExportSettings, + ): + self.clear() + + # go through all the print layouts in the project and export the requested ones + for layout in project.layoutManager().printLayouts(): + if layout.name() in export_settings.layouts: + self[layout.name()] = {} + + filename_slug = f"{slugify(layout.name())}.qpt" + os.makedirs(self.temporary_toppingfile_dir, exist_ok=True) + temporary_toppingfile_path = os.path.join( + self.temporary_toppingfile_dir, filename_slug + ) + context = QgsReadWriteContext() + result = layout.saveAsTemplate(temporary_toppingfile_path, context) + if not result: + result_message = ", ".join( + [ + message.message() + for message in context.takeMessages() + if message.level == Qgis.MessageLevel.Warning + ] + ) + logging.warning( + "Could not export layout template of {} to {}: {}".format( + layout.name(), + temporary_toppingfile_path, + result_message, + ) + ) + self[layout.name()]["templatefile"] = temporary_toppingfile_path + + def item_dict(self, target: Target): + resolved_items = {} + for layout_name in self.keys(): + resolved_item = {} + resolved_item["templatefile"] = target.toppingfile_link( + ProjectTopping.LAYOUTTEMPLATE_TYPE, + self[layout_name]["templatefile"], + ) + resolved_items[layout_name] = resolved_item + return resolved_items + def __init__(self): QObject.__init__(self) - self.layertree = self.LayerTreeItem() + temporary_toppingfile_dir = tempfile.mkdtemp( + prefix="toppingmaker_temporary_files_" + ) + + self.layertree = self.LayerTreeItem(temporary_toppingfile_dir) + self.mapthemes = self.MapThemes() self.layerorder = [] + self.variables = self.Variables() + self.layouts = self.Layouts(temporary_toppingfile_dir) def parse_project( self, project: QgsProject, export_settings: ExportSettings = ExportSettings() @@ -291,14 +504,29 @@ def parse_project( """ root = project.layerTreeRoot() if root: + # make layertree self.layertree.make_item(project, project.layerTreeRoot(), export_settings) + self.stdout.emit( + self.tr("QGIS project layertree parsed with export settings."), + Qgis.Info, + ) + # make layerorder layerorder_layers = ( root.customLayerOrder() if root.hasCustomLayerOrder() else [] ) if layerorder_layers: self.layerorder = [layer.name() for layer in layerorder_layers] + self.stdout.emit(self.tr("QGIS project layerorder parsed."), Qgis.Info) + # make mapthemes + self.mapthemes.make_items(project, export_settings) + # make variables + self.variables.make_items(project, export_settings) + # make print layouts + self.layouts.make_items(project, export_settings) + self.stdout.emit( - self.tr("QGIS project parsed with export settings."), Qgis.Info + self.tr("QGIS project map themes parsed with export settings."), + Qgis.Info, ) else: self.stdout.emit( @@ -351,9 +579,15 @@ def _projecttopping_dict(self, target: Target): """ Gets the layertree as a list of dicts. Gets the layerorder as a list. + Gets the mapthemes as a dict. + Gets the variables as a dict. + Gets the layouts as a dict. And it generates and stores the toppingfiles according th the Target. """ projecttopping_dict = {} projecttopping_dict["layertree"] = self.layertree.items_list(target) + projecttopping_dict["mapthemes"] = dict(self.mapthemes) + projecttopping_dict["variables"] = dict(self.variables) + projecttopping_dict["layouts"] = self.layouts.item_dict(target) projecttopping_dict["layerorder"] = self.layerorder return projecttopping_dict