2020"""
2121
2222import sys
23+ import tempfile
2324from enum import Enum
2425from pathlib import Path
2526from typing import Dict , List , Optional , Union
2627
2728from qgis .core import (
2829 Qgis ,
2930 QgsApplication ,
30- QgsBilinearRasterResampler ,
3131 QgsCoordinateReferenceSystem ,
3232 QgsCoordinateTransform ,
33- QgsCubicRasterResampler ,
3433 QgsEditorWidgetSetup ,
3534 QgsField ,
3635 QgsFields ,
3736 QgsLayerTreeGroup ,
37+ QgsLayerTreeModel ,
3838 QgsMapLayer ,
39+ QgsMapThemeCollection ,
3940 QgsPolygon ,
4041 QgsProcessingContext ,
4142 QgsProcessingFeedback ,
4243 QgsProject ,
4344 QgsRasterLayer ,
45+ QgsRectangle ,
4446 QgsValueRelationFieldFormatter ,
4547 QgsVectorLayer ,
4648)
@@ -70,6 +72,13 @@ class LayerData(TypedDict):
7072 LayerData = Dict
7173
7274
75+ class PackagingCanceledException (Exception ):
76+ """Exception to be raised when offline converting is canceled"""
77+
78+ def __init__ (self , * args ):
79+ super ().__init__ (QObject ().tr ("Packaging canceled by the user" ), * args )
80+
81+
7382class ExportType (Enum ):
7483 Cable = "cable"
7584 Cloud = "cloud"
@@ -81,6 +90,11 @@ class OfflineConverter(QObject):
8190 task_progress_updated = pyqtSignal (int , int )
8291 total_progress_updated = pyqtSignal (int , int , str )
8392
93+ # feedback used for basemap generation processing algorithm
94+ _feedback = QgsProcessingFeedback ()
95+
96+ _is_canceled : bool = False
97+
8498 def __init__ (
8599 self ,
86100 project : QgsProject ,
@@ -201,7 +215,13 @@ def _convert(self, project: QgsProject) -> None:
201215 copied_files = list ()
202216
203217 if self .create_basemap and self .project_configuration .create_base_map :
204- self ._export_basemap ()
218+ is_basemap_export_success = self ._export_basemap ()
219+
220+ if not is_basemap_export_success and not self ._is_canceled :
221+ self .warning .emit (
222+ self .tr ("Failed to create basemap" ),
223+ self .tr ("The basemap creation was unsuccessful." ),
224+ )
205225
206226 # We store the pks of the original vector layers
207227 for layer_idx , layer in enumerate (project_layers ):
@@ -265,6 +285,8 @@ def _convert(self, project: QgsProject) -> None:
265285 self .trUtf8 ("Copying layers…" ),
266286 )
267287
288+ self ._check_canceled ()
289+
268290 if layer_action == SyncAction .OFFLINE :
269291 offline_layers .append (layer )
270292 self .__offline_layer_names .append (layer .name ())
@@ -287,6 +309,8 @@ def _convert(self, project: QgsProject) -> None:
287309 # save the original project path
288310 self .project_configuration .original_project_path = str (self .original_filename )
289311
312+ self ._check_canceled ()
313+
290314 # save the offline project twice so that the offline plugin can "know" that it's a relative path
291315 QgsProject .instance ().write (str (export_project_filename ))
292316
@@ -301,6 +325,8 @@ def _convert(self, project: QgsProject) -> None:
301325 if not should_copy :
302326 continue
303327
328+ self ._check_canceled ()
329+
304330 copy_attachments (
305331 self .original_filename .parent ,
306332 export_project_filename .parent ,
@@ -310,6 +336,8 @@ def _convert(self, project: QgsProject) -> None:
310336 # copy project plugin if present
311337 plugin_file = Path ("{}.qml" .format (str (self .original_filename )[:- 4 ]))
312338 if plugin_file .exists ():
339+ self ._check_canceled ()
340+
313341 copy_multifile (
314342 plugin_file , export_project_filename .parent .joinpath (plugin_file .name )
315343 )
@@ -323,6 +351,8 @@ def _convert(self, project: QgsProject) -> None:
323351 QgsProject .instance (),
324352 ).transformBoundingBox (self .area_of_interest .boundingBox ())
325353
354+ self ._check_canceled ()
355+
326356 is_success = self .offliner .convert_to_offline (
327357 str (self ._export_filename .with_name ("data.gpkg" )),
328358 offline_layers ,
@@ -337,10 +367,14 @@ def _convert(self, project: QgsProject) -> None:
337367 )
338368 )
339369
370+ self ._check_canceled ()
371+
340372 # Disable project options that could create problems on a portable
341373 # project with offline layers
342374 self .post_process_offline_layers ()
343375
376+ self ._check_canceled ()
377+
344378 # Now we have a project state which can be saved as offline project
345379 on_original_project_write = self ._on_original_project_write_wrapper (
346380 xml_elements_to_preserve
@@ -556,53 +590,125 @@ def _export_basemap(self) -> bool:
556590 )
557591 return False
558592
559- extent_string = "{},{},{},{}" .format (
560- extent .xMinimum (),
561- extent .xMaximum (),
562- extent .yMinimum (),
563- extent .yMaximum (),
564- )
593+ exported_mbtiles = self ._export_basemap_as_mbtiles (extent , base_map_type )
594+
595+ return exported_mbtiles
596+
597+ def _export_basemap_as_mbtiles (
598+ self , extent : QgsRectangle , base_map_type : ProjectProperties .BaseMapType
599+ ) -> bool :
600+ """
601+ Exports a basemap to mbtiles format.
602+ This method handles several zoom levels.
603+ This should be preferred over the legacy `_export_basemap_as_tiff` method.
604+
605+ Args:
606+ extent (QgsRectangle): extent of the area of interest
607+ base_map_type (ProjectProperties.BaseMapType): basemap type (layer or theme)
608+
609+ Returns:
610+ bool: if basemap layer could be exported as mbtiles
611+ """
565612
566613 alg = (
567614 QgsApplication .instance ()
568615 .processingRegistry ()
569- .createAlgorithmById ("native:rasterize " )
616+ .createAlgorithmById ("native:tilesxyzmbtiles " )
570617 )
571618
572619 params = {
573- "EXTENT" : extent_string ,
574- "EXTENT_BUFFER" : 0 ,
575- "TILE_SIZE" : self .project_configuration .base_map_tile_size ,
576- "MAP_UNITS_PER_PIXEL" : self .project_configuration .base_map_mupp ,
577- "MAKE_BACKGROUND_TRANSPARENT" : False ,
578- "OUTPUT" : str (self ._export_filename .with_name ("basemap.gpkg" )),
620+ "EXTENT" : extent ,
621+ "ZOOM_MIN" : self .project_configuration .base_map_tiles_min_zoom_level ,
622+ "ZOOM_MAX" : self .project_configuration .base_map_tiles_max_zoom_level ,
623+ "TILE_SIZE" : 256 ,
624+ "OUTPUT_FILE" : str (self ._export_filename .with_name ("basemap.mbtiles" )),
579625 }
580626
627+ # clone current QGIS project
628+ current_project = QgsProject .instance ()
629+ cloned_project = QgsProject (
630+ parent = current_project .parent (), capabilities = current_project .capabilities ()
631+ )
632+ cloned_project .setCrs (current_project .crs ())
633+
581634 if base_map_type == ProjectProperties .BaseMapType .SINGLE_LAYER :
582- params ["LAYERS" ] = [self .project_configuration .base_map_layer ]
635+ # the `native:tilesxyzmbtiles` alg does not have any LAYERS param
636+ # so just add basemap layer to the cloned project
637+ basemap_layer = current_project .mapLayer (
638+ self .project_configuration .base_map_layer
639+ )
640+ # here we use a cloned version of the raster layer, otherwise QGIS might crash
641+ clone_layer = basemap_layer .clone ()
642+ cloned_project .addMapLayer (clone_layer )
643+
583644 elif base_map_type == ProjectProperties .BaseMapType .MAP_THEME :
584- params ["MAP_THEME" ] = self .project_configuration .base_map_theme
645+ # clone and recreate the current QGIS project, and recreate original themes
646+ current_themes = QgsMapThemeCollection (current_project )
647+ themes_data = {}
648+
649+ for theme_name in current_themes .mapThemes ():
650+ layers , visibility = current_themes .mapThemeLayers (theme_name )
651+ themes_data [theme_name ] = (layers , visibility )
652+
653+ # create a temp file to store current QGIS project
654+ temp_file = tempfile .NamedTemporaryFile (suffix = ".qgz" , delete = False )
655+ temp_path = temp_file .name
656+ temp_file .close ()
657+ current_project .write (temp_path )
658+
659+ cloned_project .read (temp_path )
660+
661+ cloned_themes_collection = QgsMapThemeCollection (cloned_project )
662+ for theme_name , (layers , visibility ) in themes_data .items ():
663+ cloned_themes_collection .storeMapTheme (theme_name , layers , visibility )
664+
665+ layer_tree_root = cloned_project .layerTreeRoot ()
666+ layer_tree_model = QgsLayerTreeModel (layer_tree_root )
667+ cloned_project .mapThemeCollection ().applyTheme (
668+ self .project_configuration .base_map_theme ,
669+ layer_tree_root ,
670+ layer_tree_model ,
671+ )
585672
586- feedback = QgsProcessingFeedback ()
587673 context = QgsProcessingContext ()
588- context .setProject (QgsProject . instance () )
674+ context .setProject (cloned_project )
589675
590- results , ok = alg .run (params , context , feedback )
676+ # connect subtask feedback progress signal
677+ self ._feedback .progressChanged .connect (self ._on_tiles_gen_alg_progress_changed )
591678
592- if not ok :
593- self .warning .emit (self .tr ("Failed to create basemap" ), feedback .textLog ())
594- return False
679+ # we use a try clause to make sure the feedback's `progressChanged` signal
680+ # is disconnected in the finally clause.
681+ try :
682+ results , ok = alg .run (params , context , self ._feedback )
683+
684+ if not ok :
685+ self .warning .emit (
686+ self .tr ("Failed to create mbtiles basemap" ),
687+ self ._feedback .textLog (),
688+ )
689+ return False
690+
691+ new_layer = QgsRasterLayer (results ["OUTPUT_FILE" ], self .tr ("Basemap" ))
692+
693+ self .project_configuration .project .addMapLayer (new_layer , False )
595694
596- new_layer = QgsRasterLayer (results ["OUTPUT" ], self .tr ("Basemap" ))
695+ layer_tree = QgsProject .instance ().layerTreeRoot ()
696+ layer_tree .insertLayer (len (layer_tree .children ()), new_layer )
597697
598- resample_filter = new_layer .resampleFilter ()
599- resample_filter .setZoomedInResampler (QgsCubicRasterResampler ())
600- resample_filter .setZoomedOutResampler (QgsBilinearRasterResampler ())
601- self .project_configuration .project .addMapLayer (new_layer , False )
602- layer_tree = QgsProject .instance ().layerTreeRoot ()
603- layer_tree .insertLayer (len (layer_tree .children ()), new_layer )
698+ return True
604699
605- return True
700+ finally :
701+ self ._feedback .progressChanged .disconnect ()
702+
703+ def _on_tiles_gen_alg_progress_changed (self , revision : float ) -> None :
704+ """
705+ Called when the native `native:tilesxyzmbtiles` algorithm's execution emits progress.
706+ This method will notify the accurate signal about this progress, e.g. QFieldSync progress bar UI.
707+
708+ Args:
709+ revision (float): progress value of the tiles generation algorithm (between 0 and 100)
710+ """
711+ self .task_progress_updated .emit (int (revision ), 100 )
606712
607713 def _on_offline_editing_next_layer (self , layer_index , layer_count ):
608714 msg = self .trUtf8 ("Packaging layer {layer_name}…" ).format (
@@ -634,6 +740,20 @@ def on_original_project_write(doc):
634740 def _on_offline_editing_task_progress (self , progress ):
635741 self .task_progress_updated .emit (progress , self .__max_task_progress )
636742
743+ def cancel (self ) -> None :
744+ """
745+ Cancels the offline packaging of a QField project.
746+ Typically used when the QField export dialog is closed.
747+ """
748+ self ._is_canceled = True
749+ self ._feedback .cancel ()
750+
751+ def _check_canceled (self ) -> None :
752+ """Checks if packaging has been and should be canceled."""
753+ QCoreApplication .processEvents ()
754+ if self ._is_canceled :
755+ raise PackagingCanceledException ()
756+
637757 def convertorProcessingProgress (self ):
638758 """
639759 Will create a new progress object for processing to get feedback from the basemap
0 commit comments