89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/qtiles/metadata.txt:
--------------------------------------------------------------------------------
1 | [general]
2 | name=QTiles
3 | description=Generate map tiles from a QGIS project
4 | description[ru]=Создавайте растровые тайлы из проекта QGIS
5 | about=Generates raster tiles from QGIS project for selected zoom levels and tile naming conventions (Slippy Map or TMS). Package tiles for NextGIS Mobile, GeoPaparazzi, simple Leaflet-based viewer or MBTiles. Developed by NextGIS. Any feedback is welcome at https://nextgis.com/contact
6 | about[ru]=Создает растровые тайлы из проекта QGIS для выбранных уровней масштабирования в соответствии с соглашениями о наименовании тайлов (Slippy Map или TMS). Набор тайлов для NextGIS Mobile, GeoPaparazzi, простого просмотра карт на основе Leaflet или MBTiles. Разработан NextGIS. Любые отзывы приветствуются на https://nextgis.com/contact
7 | category=Plugins
8 | version=1.8.0
9 | qgisMinimumVersion=3.22
10 | qgisMaximumVersion=3.99
11 |
12 | author=NextGIS
13 | email=info@nextgis.com
14 |
15 | changelog=
16 | 1.8.0
17 | * Updated the "About plugin" dialog
18 | * Added plugin item to help menu
19 | 1.7.2
20 | * Fix rounding error on Python 3.10
21 | 1.7.1
22 | * Fixed file selection dialog
23 | 1.7.0
24 | * Fixed bugs
25 | 1.6.0
26 | * QGIS 3 support added
27 | 1.5.5
28 | * Fix rendering of tiles outside of layer extent
29 | * Fix qgis warnings
30 | 1.5.4
31 | * Allow JPG as format for NGRC
32 | 1.5.3
33 | * Fix problem with 65356 tiles limit
34 | 1.5.2
35 | * Removed the limitation of the maximum zoom
36 | * Host css+js in local repository for LeafLet preview
37 | 1.5.1:
38 | * create tiles for NextGIS Mobile
39 | * add MBTiles compression
40 | * add export MBTiles metadata to .json file
41 | * add image overview for MBTiles
42 | * add option for skiping tiles outside of layers extents (within combined extent)
43 | 1.5.0:
44 | * change MBTiles parameters vаlues: format in lower case, description is 'Created with QTiles'
45 | * tiles are now produced correctly when transparency is set
46 | * geojson is now rendered correctly
47 | * CRS shift when using 3857 is fixed
48 | 1.4.6:
49 | * works fine now with non-english characters in folder names
50 | * add MBTiles initialize arguments for Geopaparazzi4
51 | * take into account the actual zoom level when generating tiles
52 |
53 | icon=icons/qtiles.png
54 |
55 | tags=raster,tiles
56 |
57 | homepage=https://github.com/nextgis/QTiles
58 | user_guide=https://docs.nextgis.com/docs_ngqgis/source/qtiles.html
59 | user_guide[ru]=https://docs.nextgis.ru/docs_ngqgis/source/qtiles.html
60 | tracker=https://github.com/nextgis/QTiles/issues
61 | repository=https://github.com/nextgis/QTiles
62 | video=https://www.youtube.com/watch?v=vU4bGCh5khM
63 | video[ru]=https://www.youtube.com/watch?v=Lk-i4Az0SEo
64 |
65 | experimental=False
66 | deprecated=False
67 | supportsQt6=True
68 |
--------------------------------------------------------------------------------
/src/qtiles/icons/nextgis_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
32 |
--------------------------------------------------------------------------------
/src/qtiles/compat.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # ******************************************************************************
3 | #
4 | # OSMInfo
5 | # ---------------------------------------------------------
6 | # This plugin takes coordinates of a mouse click and gets information about all
7 | # objects from this point from OSM using Overpass API.
8 | #
9 | # Author: Denis Ilyin, denis.ilyin@nextgis.com
10 | # *****************************************************************************
11 | # Copyright (c) 2015-2021. NextGIS, info@nextgis.com
12 | #
13 | # This source is free software; you can redistribute it and/or modify it under
14 | # the terms of the GNU General Public License as published by the Free
15 | # Software Foundation, either version 2 of the License, or (at your option)
16 | # any later version.
17 | #
18 | # This code is distributed in the hope that it will be useful, but WITHOUT ANY
19 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
20 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
21 | # details.
22 | #
23 | # A copy of the GNU General Public License is available on the World Wide Web
24 | # at . You can also obtain it by writing
25 | # to the Free Software Foundation, 51 Franklin Street, Suite 500 Boston,
26 | # MA 02110-1335 USA.
27 | #
28 | # ******************************************************************************
29 |
30 | import sys
31 |
32 | from qgis import core
33 |
34 | PY2 = sys.version_info[0] == 2
35 | PY3 = sys.version_info[0] == 3
36 |
37 | if PY3:
38 | import configparser
39 | else:
40 | import ConfigParser as configparser
41 |
42 | if hasattr(core, "QGis"):
43 | from qgis.core import QGis
44 | else:
45 | from qgis.core import Qgis as QGis
46 |
47 | if QGis.QGIS_VERSION_INT >= 30000:
48 | QGIS_VERSION_3 = True
49 |
50 | mapLayers = core.QgsProject.instance().mapLayers
51 |
52 | from qgis.core import QgsPointXY, QgsSettings
53 |
54 | QgsMessageLogInfo = QGis.Info
55 |
56 | qgisUserDatabaseFilePath = core.QgsApplication.qgisUserDatabaseFilePath
57 | else:
58 | QGIS_VERSION_3 = False
59 |
60 | mapLayers = core.QgsMapLayerRegistry.instance().mapLayers
61 |
62 | from qgis.core import QgsPoint as QgsPointXY
63 | from qgis.PyQt.QtCore import QSettings as QgsSettings
64 |
65 | QgsMessageLogInfo = core.QgsMessageLog.INFO
66 |
67 | qgisUserDatabaseFilePath = core.QgsApplication.qgisUserDbFilePath
68 |
69 |
70 | class QgsCoordinateTransform(core.QgsCoordinateTransform):
71 | def __init__(self, src_crs, dst_crs):
72 | super(QgsCoordinateTransform, self).__init__()
73 |
74 | self.setSourceCrs(src_crs)
75 | self.setDestinationCrs(dst_crs)
76 |
77 | def setDestinationCrs(self, dst_crs):
78 | if QGis.QGIS_VERSION_INT >= 30000:
79 | super(QgsCoordinateTransform, self).setDestinationCrs(dst_crs)
80 | else:
81 | self.setDestCRS(dst_crs)
82 |
83 |
84 | class QgsCoordinateReferenceSystem(core.QgsCoordinateReferenceSystem):
85 | def __init__(self, id, type):
86 | if QGis.QGIS_VERSION_INT >= 30000:
87 | super(QgsCoordinateReferenceSystem, self).__init__(
88 | core.QgsCoordinateReferenceSystem.fromEpsgId(id)
89 | )
90 | else:
91 | super(QgsCoordinateReferenceSystem, self).__init__(id, type)
92 |
93 | @staticmethod
94 | def fromEpsgId(id):
95 | if QGis.QGIS_VERSION_INT >= 30000:
96 | return core.QgsCoordinateReferenceSystem.fromEpsgId(id)
97 | else:
98 | return core.QgsCoordinateReferenceSystem(id)
99 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110 | .pdm.toml
111 | .pdm-python
112 | .pdm-build/
113 |
114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115 | __pypackages__/
116 |
117 | # Celery stuff
118 | celerybeat-schedule
119 | celerybeat.pid
120 |
121 | # SageMath parsed files
122 | *.sage.py
123 |
124 | # Environments
125 | .env
126 | .venv
127 | env/
128 | venv/
129 | ENV/
130 | env.bak/
131 | venv.bak/
132 |
133 | # Spyder project settings
134 | .spyderproject
135 | .spyproject
136 |
137 | # Rope project settings
138 | .ropeproject
139 |
140 | # mkdocs documentation
141 | /site
142 |
143 | # mypy
144 | .mypy_cache/
145 | .dmypy.json
146 | dmypy.json
147 |
148 | # Pyre type checker
149 | .pyre/
150 |
151 | # pytype static type analyzer
152 | .pytype/
153 |
154 | # Cython debug symbols
155 | cython_debug/
156 |
157 | # PyCharm
158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
160 | # and can be added to the global gitignore or merged into this file. For a more nuclear
161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
162 | #.idea/
163 |
164 | # C++ objects and libs
165 | *.slo
166 | *.lo
167 | *.o
168 | *.a
169 | *.la
170 | *.lai
171 | *.so
172 | *.so.*
173 | *.dll
174 | *.dylib
175 |
176 | # Qt-es
177 | object_script.*.Release
178 | object_script.*.Debug
179 | *_plugin_import.cpp
180 | /.qmake.cache
181 | /.qmake.stash
182 | *.pro.user
183 | *.pro.user.*
184 | *.qbs.user
185 | *.qbs.user.*
186 | *.moc
187 | moc_*.cpp
188 | moc_*.h
189 | qrc_*.cpp
190 | ui_*.h
191 | *.qmlc
192 | *.jsc
193 | Makefile*
194 | *build-*
195 | *.qm
196 | *.prl
197 |
198 | # Qt unit tests
199 | target_wrapper.*
200 |
201 | # QtCreator
202 | *.autosave
203 |
204 | # QtCreator Qml
205 | *.qmlproject.user
206 | *.qmlproject.user.*
207 |
208 | # QtCreator CMake
209 | CMakeLists.txt.user*
210 |
211 | # QtCreator 4.8< compilation database
212 | compile_commands.json
213 |
214 | # QtCreator local machine specific files for imported projects
215 | *creator.user*
216 |
217 | *_qmlcache.qrc
218 |
219 | # PyQt
220 | ui_*.py
221 | *_rc.py
222 |
223 | # QGIS
224 | *.db
225 |
226 | # VS Code
227 | /.vscode
--------------------------------------------------------------------------------
/src/qtiles/restrictions.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import List, Tuple
3 |
4 | from qgis.core import QgsMapLayer
5 | from qgis.PyQt.QtCore import QCoreApplication, QUrl
6 |
7 |
8 | class LayerRestriction(ABC):
9 | """
10 | Abstract base class for defining restrictions on map layers.
11 | """
12 |
13 | @abstractmethod
14 | def validate_restriction(
15 | self, layers: List[QgsMapLayer], tiles_count: int
16 | ) -> Tuple[bool, str, List[QgsMapLayer]]:
17 | """
18 | Validates whether the given layers and tile count meet the restriction.
19 |
20 | Subclasses should implement this method to define their own logic for
21 | checking specific restriction (e.g., OpenStreetMap usage policy).
22 |
23 | :param layers: A list of map layers to check.
24 | :type layers: List[QgsMapLayer]
25 |
26 | :param tiles_count: The total number of tiles to be generated.
27 | :type tiles_count: int
28 |
29 | :returns: A tuple containing:
30 | - **bool** – whether the restriction is violated,
31 | - **str** – an HTML message describing skipped layers (if any),
32 | - **List[QgsMapLayer]** – list of layers to be skipped.
33 | :rtype: Tuple[bool, str, List[QgsMapLayer]]
34 | """
35 | pass
36 |
37 |
38 | class OpenStreetMapRestriction(LayerRestriction):
39 | """
40 | Restriction for OpenStreetMap layers to prevent bulk downloading.
41 |
42 | This class enforces the OpenStreetMap Tile Usage Policy by skipping
43 | layers that would result in excessive tile downloads.
44 | """
45 |
46 | MAXIMUM_OPENSTREETMAP_TILES_FETCH: int = 5000
47 |
48 | def is_openstreetmap_layer(self, layer: QgsMapLayer) -> bool:
49 | """
50 | Determines whether a given layer is an OpenStreetMap layer.
51 |
52 | This method checks the provider type and URL of the layer to identify
53 | if it belongs to OpenStreetMap.
54 |
55 | :param layer: The map layer to check.
56 | """
57 | if layer.providerType().lower() != "wms":
58 | return False
59 |
60 | metadata = layer.providerMetadata()
61 | uri = metadata.decodeUri(layer.source())
62 | url = QUrl(uri.get("url", ""))
63 | host = url.host().lower()
64 |
65 | return host.endswith("openstreetmap.org") or host.endswith("osm.org")
66 |
67 | def validate_restriction(
68 | self, layers: List[QgsMapLayer], tiles_count: int
69 | ) -> Tuple[bool, str, List[QgsMapLayer]]:
70 | """
71 | Checks if the tile count exceeds the maximum allowed for OpenStreetMap.
72 |
73 | :param layers: A list of map layers to check.
74 | :type layers: List[QgsMapLayer]
75 |
76 | :param tiles_count: The total number of tiles to be generated.
77 | :type tiles_count: int
78 |
79 | :returns: A tuple containing:
80 | - **bool** – whether the restriction is violated,
81 | - **str** – an HTML message describing skipped layers (if any),
82 | - **List[QgsMapLayer]** – list of layers to be skipped.
83 | :rtype: Tuple[bool, str, List[QgsMapLayer]]
84 | """
85 | if tiles_count <= self.MAXIMUM_OPENSTREETMAP_TILES_FETCH:
86 | return False, "", []
87 |
88 | osm_layers = [
89 | layer for layer in layers if self.is_openstreetmap_layer(layer)
90 | ]
91 |
92 | if not osm_layers:
93 | return False, "", []
94 |
95 | layers_list_html = " ".join(layer.name() for layer in osm_layers)
96 | message = f"""
97 |
{
98 | QCoreApplication.translate(
99 | "LayerRestriction",
100 | "The following OpenStreetMap layers were skipped because the operation "
101 | "would lead to bulk downloading, which is prohibited by the "
102 | "OpenStreetMap Foundation Tile Usage Policy:",
103 | )
104 | }
{
111 | QCoreApplication.translate(
112 | "LayerRestriction",
113 | "There are no layers remaining for tiling. "
114 | "The operation has been cancelled.",
115 | )
116 | }
117 | """
118 |
119 | message += f"""
120 |
{
121 | QCoreApplication.translate(
122 | "LayerRestriction",
123 | "To avoid this restriction, try reducing the maximum zoom level in the settings "
124 | "or increasing the zoom level in the map extent before running operation.",
125 | )
126 | }
274 |
275 | """
276 |
277 | replacements = dict()
278 | replacements.update(titles)
279 | replacements.update(metadata)
280 |
281 | return (description + services).format_map(replacements)
282 |
283 | def __tab_to_index(self, tab_name: AboutTab) -> int:
284 | tab = self.tab_widget.findChild(QWidget, str(tab_name))
285 | return self.tab_widget.indexOf(tab)
286 |
--------------------------------------------------------------------------------
/src/qtiles/writers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | # ******************************************************************************
4 | #
5 | # QTiles
6 | # ---------------------------------------------------------
7 | # Generates tiles from QGIS project
8 | #
9 | # Copyright (C) 2012-2022 NextGIS (info@nextgis.com)
10 | #
11 | # This source is free software; you can redistribute it and/or modify it under
12 | # the terms of the GNU General Public License as published by the Free
13 | # Software Foundation, either version 2 of the License, or (at your option)
14 | # any later version.
15 | #
16 | # This code is distributed in the hope that it will be useful, but WITHOUT ANY
17 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
18 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
19 | # details.
20 | #
21 | # A copy of the GNU General Public License is available on the World Wide Web
22 | # at . You can also obtain it by writing
23 | # to the Free Software Foundation, 51 Franklin Street, Suite 500 Boston,
24 | # MA 02110-1335 USA.
25 | #
26 | # ******************************************************************************
27 |
28 | import json
29 | import sqlite3
30 | import zipfile
31 | from pathlib import Path
32 | from typing import Dict, List
33 |
34 | from qgis.core import QgsRectangle
35 | from qgis.PyQt.QtCore import (
36 | QBuffer,
37 | QByteArray,
38 | QIODevice,
39 | QTemporaryFile,
40 | )
41 | from qgis.PyQt.QtGui import QImage
42 |
43 | from qtiles.tile import Tile
44 |
45 | from .mbutils import *
46 |
47 |
48 | class DirectoryWriter:
49 | """
50 | Handles writing tiles to a directory structure.
51 |
52 | This class organizes tiles into a folder hierarchy based on zoom levels
53 | and coordinates. It ensures that the directory structure is created
54 | dynamically as tiles are written.
55 | """
56 |
57 | def __init__(self, output_path: Path, root_dir: str) -> None:
58 | """
59 | Initializes the DirectoryWriter with the output path and root directory.
60 |
61 | :param output_path: The base directory where tiles will be saved.
62 | :param root_dir: The root directory name for the tile structure.
63 | """
64 | self.output_path = output_path
65 | self.root_dir = root_dir
66 |
67 | def writeTile(
68 | self, tile: Tile, image: QImage, format: str, quality: int
69 | ) -> None:
70 | """
71 | Saves a single image tile to the appropriate directory based on zoom level and coordinates.
72 |
73 | The method creates the necessary directory structure if it doesn't already exist,
74 | and writes the tile image to disk using the specified format and quality.
75 |
76 | :param tile: A Tile object containing the zoom level (z), x, and y coordinates.
77 | :param image: The QImage representing the tile image to be saved.
78 | :param format: The image format (e.g., 'PNG', 'JPEG') to use for saving.
79 | :param quality: The image quality (0–100), where higher values indicate better quality.
80 | """
81 | dir_path = self.output_path / self.root_dir / str(tile.z) / str(tile.x)
82 | dir_path.mkdir(parents=True, exist_ok=True)
83 |
84 | tile_file = dir_path / f"{tile.y}.{format.lower()}"
85 | image.save(str(tile_file), format, quality)
86 |
87 | def finalize(self) -> None:
88 | """
89 | Finalizes the writing process.
90 | """
91 | pass
92 |
93 |
94 | class ZipWriter:
95 | """
96 | Handles writing tiles to a ZIP archive.
97 |
98 | This class compresses tiles into a ZIP file, maintaining a folder
99 | structure within the archive based on zoom levels and coordinates.
100 | """
101 |
102 | def __init__(self, output_path: Path, root_dir: str) -> None:
103 | """
104 | Initializes the ZipWriter with the output path and root directory.
105 |
106 | :param output_path: The path to the ZIP file to be created.
107 | :param root_dir: The root directory name for the tile structure.
108 | """
109 | self.output_path = output_path
110 | self.root_dir = root_dir
111 |
112 | self.zip_file = zipfile.ZipFile(
113 | str(self.output_path), "w", allowZip64=True
114 | )
115 | self.temp_file = QTemporaryFile()
116 | self.temp_file.setAutoRemove(False)
117 | self.temp_file.open(QIODevice.OpenModeFlag.WriteOnly)
118 | self.temp_file_name = self.temp_file.fileName()
119 | self.temp_file.close()
120 |
121 | def writeTile(
122 | self, tile: Tile, image: QImage, format: str, quality: int
123 | ) -> None:
124 | """
125 | Saves a tile image to the ZIP archive.
126 |
127 | :param tile: The tile object containing zoom level and coordinates.
128 | :param image: The image to save.
129 | :param format: The image format (e.g., PNG, JPG).
130 | :param quality: The quality of the saved image.
131 | """
132 | tile_path = (
133 | f"{self.root_dir}/{tile.z}/{tile.x}/{tile.y}.{format.lower()}"
134 | )
135 |
136 | image.save(self.temp_file_name, format, quality)
137 |
138 | self.zip_file.write(self.temp_file_name, arcname=tile_path)
139 |
140 | def finalize(self) -> None:
141 | """
142 | Finalizes the writing process.
143 |
144 | This method closes the ZIP file and removes temporary files used
145 | during the writing process.
146 | """
147 | self.temp_file.close()
148 | self.temp_file.remove()
149 | self.zip_file.close()
150 |
151 |
152 | class NGMArchiveWriter(ZipWriter):
153 | """
154 | Specialized writer for creating NGM archives.
155 |
156 | This class extends ZipWriter to include metadata specific to NGM
157 | archives, such as tile levels and renderer properties.
158 | """
159 |
160 | def __init__(self, output_path: Path, root_dir: str) -> None:
161 | """
162 | Initializes the NGMArchiveWriter with the output path and root directory.
163 |
164 | :param output_path: The path to the NGM archive to be created.
165 | :param root_dir: The root directory name for the tile structure.
166 | """
167 | super().__init__(output_path, "Mapnik")
168 | self.levels: Dict[int, Dict[str, List[int]]] = {}
169 | self.__layer_name = root_dir
170 |
171 | def writeTile(
172 | self, tile: Tile, image: QImage, format: str, quality: int
173 | ) -> None:
174 | """
175 | Saves a tile image to the NGM archive.
176 |
177 | :param tile: The tile object containing zoom level and coordinates.
178 | :param image: The image to save.
179 | :param format: The image format (e.g., PNG, JPG).
180 | :param quality: The quality of the saved image.
181 | """
182 | super().writeTile(tile, image, format, quality)
183 | level = self.levels.get(tile.z, {"x": [], "y": []})
184 | level["x"].append(tile.x)
185 | level["y"].append(tile.y)
186 |
187 | self.levels[tile.z] = level
188 |
189 | def finalize(self) -> None:
190 | """
191 | Finalizes the writing process by adding metadata to the archive.
192 |
193 | This method generates a JSON file containing metadata about the
194 | tile levels and renderer properties, which is then added to the
195 | archive.
196 | """
197 | archive_info = {
198 | "cache_size_multiply": 0,
199 | "levels": [],
200 | "max_level": max(self.levels.keys()),
201 | "min_level": min(self.levels.keys()),
202 | "name": self.__layer_name,
203 | "renderer_properties": {
204 | "alpha": 255,
205 | "antialias": True,
206 | "brightness": 0,
207 | "contrast": 1,
208 | "dither": True,
209 | "filterbitmap": True,
210 | "greyscale": False,
211 | "type": "tms_renderer",
212 | },
213 | "tms_type": 2,
214 | "type": 32,
215 | "visible": True,
216 | }
217 |
218 | for level, coords in list(self.levels.items()):
219 | level_json = {
220 | "level": level,
221 | "bbox_maxx": max(coords["x"]),
222 | "bbox_maxy": max(coords["y"]),
223 | "bbox_minx": min(coords["x"]),
224 | "bbox_miny": min(coords["y"]),
225 | }
226 |
227 | archive_info["levels"].append(level_json)
228 |
229 | json_bytes = json.dumps(archive_info).encode("utf-8")
230 | json_name = f"{self.root_dir}.json"
231 | self.zip_file.writestr(json_name, json_bytes)
232 |
233 | super().finalize()
234 |
235 |
236 | class MBTilesWriter:
237 | """
238 | Handles writing tiles to an MBTiles SQLite database.
239 |
240 | This class stores generated tiles inside an MBTiles database,
241 | including associated metadata such as zoom levels, extent, and compression information.
242 | """
243 |
244 | def __init__(
245 | self,
246 | output_path: Path,
247 | root_dir: str,
248 | formatext: str,
249 | min_zoom: int,
250 | max_zoom: int,
251 | extent: QgsRectangle,
252 | compression: bool,
253 | ) -> None:
254 | """
255 | Initializes the MBTilesWriter with output parameters and database setup.
256 |
257 | :param output_path: The path to the MBTiles file to be created.
258 | :param root_dir: The name of the root directory or layer (used in metadata).
259 | :param formatext: The image format (e.g., PNG, JPG) to store tiles.
260 | :param min_zoom: The minimum zoom level.
261 | :param max_zoom: The maximum zoom level.
262 | :param extent: The geographic extent (bounding box) of the tiles.
263 | :param compression: Whether to apply tile data compression after writing.
264 | """
265 | self.output_path = output_path
266 | self.root_dir = root_dir
267 |
268 | self.compression = compression
269 | bounds = f"{extent.xMinimum()},{extent.yMinimum()},{extent.xMaximum()},{extent.yMaximum()}"
270 | self.connection = mbtiles_connect(str(self.output_path), silent=False)
271 | self.cursor = self.connection.cursor()
272 | optimize_connection(self.cursor)
273 | mbtiles_setup(self.cursor)
274 | self.cursor.execute(
275 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
276 | ("name", self.root_dir),
277 | )
278 | self.cursor.execute(
279 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
280 | ("description", "Created with QTiles"),
281 | )
282 | self.cursor.execute(
283 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
284 | ("format", formatext.lower()),
285 | )
286 | self.cursor.execute(
287 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
288 | ("minZoom", str(min_zoom)),
289 | )
290 | self.cursor.execute(
291 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
292 | ("maxZoom", str(max_zoom)),
293 | )
294 | self.cursor.execute(
295 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
296 | ("type", "baselayer"),
297 | )
298 | self.cursor.execute(
299 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
300 | ("version", "1.1"),
301 | )
302 | self.cursor.execute(
303 | """INSERT INTO metadata(name, value) VALUES (?, ?);""",
304 | ("bounds", bounds),
305 | )
306 | self.connection.commit()
307 |
308 | def writeTile(
309 | self, tile: Tile, image: QImage, format: str, quality: int
310 | ) -> None:
311 | """
312 | Inserts a single tile into the MBTiles database.
313 |
314 | :param tile: The tile object containing zoom level and coordinates.
315 | :param image: The image data to store.
316 | :param format: The image format (e.g., PNG, JPG).
317 | :param quality: The quality level for the saved image.
318 | """
319 | data = QByteArray()
320 | buff = QBuffer(data)
321 | image.save(buff, format, quality)
322 |
323 | self.cursor.execute(
324 | """INSERT INTO tiles(zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?);""",
325 | (tile.z, tile.x, tile.y, sqlite3.Binary(buff.data())),
326 | )
327 | buff.close()
328 |
329 | def finalize(self) -> None:
330 | """
331 | Finalizes the writing process.
332 |
333 | This method optimizes the database, optionally compresses tile data,
334 | and properly closes the database connection.
335 | """
336 | self.connection.commit()
337 | if self.compression:
338 | # start compression
339 | compression_prepare(self.cursor, self.connection)
340 | self.cursor.execute("select count(zoom_level) from tiles")
341 | res = self.cursor.fetchone()
342 | total_tiles = res[0]
343 | compression_do(
344 | self.cursor, self.connection, total_tiles, silent=False
345 | )
346 | compression_finalize(self.cursor, self.connection, silent=False)
347 | self.connection.commit()
348 | # end compression
349 |
350 | optimize_database(self.connection, silent=False)
351 | self.connection.close()
352 | self.cursor = None
353 |
--------------------------------------------------------------------------------
/src/qtiles/resources/css/leaflet.css:
--------------------------------------------------------------------------------
1 | /* required styles */
2 |
3 | .leaflet-pane,
4 | .leaflet-tile,
5 | .leaflet-marker-icon,
6 | .leaflet-marker-shadow,
7 | .leaflet-tile-container,
8 | .leaflet-pane > svg,
9 | .leaflet-pane > canvas,
10 | .leaflet-zoom-box,
11 | .leaflet-image-layer,
12 | .leaflet-layer {
13 | position: absolute;
14 | left: 0;
15 | top: 0;
16 | }
17 | .leaflet-container {
18 | overflow: hidden;
19 | }
20 | .leaflet-tile,
21 | .leaflet-marker-icon,
22 | .leaflet-marker-shadow {
23 | -webkit-user-select: none;
24 | -moz-user-select: none;
25 | user-select: none;
26 | -webkit-user-drag: none;
27 | }
28 | /* Prevents IE11 from highlighting tiles in blue */
29 | .leaflet-tile::selection {
30 | background: transparent;
31 | }
32 | /* Safari renders non-retina tile on retina better with this, but Chrome is worse */
33 | .leaflet-safari .leaflet-tile {
34 | image-rendering: -webkit-optimize-contrast;
35 | }
36 | /* hack that prevents hw layers "stretching" when loading new tiles */
37 | .leaflet-safari .leaflet-tile-container {
38 | width: 1600px;
39 | height: 1600px;
40 | -webkit-transform-origin: 0 0;
41 | }
42 | .leaflet-marker-icon,
43 | .leaflet-marker-shadow {
44 | display: block;
45 | }
46 | /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
47 | /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
48 | .leaflet-container .leaflet-overlay-pane svg {
49 | max-width: none !important;
50 | max-height: none !important;
51 | }
52 | .leaflet-container .leaflet-marker-pane img,
53 | .leaflet-container .leaflet-shadow-pane img,
54 | .leaflet-container .leaflet-tile-pane img,
55 | .leaflet-container img.leaflet-image-layer,
56 | .leaflet-container .leaflet-tile {
57 | max-width: none !important;
58 | max-height: none !important;
59 | width: auto;
60 | padding: 0;
61 | }
62 |
63 | .leaflet-container img.leaflet-tile {
64 | /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
65 | mix-blend-mode: plus-lighter;
66 | }
67 |
68 | .leaflet-container.leaflet-touch-zoom {
69 | -ms-touch-action: pan-x pan-y;
70 | touch-action: pan-x pan-y;
71 | }
72 | .leaflet-container.leaflet-touch-drag {
73 | -ms-touch-action: pinch-zoom;
74 | /* Fallback for FF which doesn't support pinch-zoom */
75 | touch-action: none;
76 | touch-action: pinch-zoom;
77 | }
78 | .leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
79 | -ms-touch-action: none;
80 | touch-action: none;
81 | }
82 | .leaflet-container {
83 | -webkit-tap-highlight-color: transparent;
84 | }
85 | .leaflet-container a {
86 | -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
87 | }
88 | .leaflet-tile {
89 | filter: inherit;
90 | visibility: hidden;
91 | }
92 | .leaflet-tile-loaded {
93 | visibility: inherit;
94 | }
95 | .leaflet-zoom-box {
96 | width: 0;
97 | height: 0;
98 | -moz-box-sizing: border-box;
99 | box-sizing: border-box;
100 | z-index: 800;
101 | }
102 | /* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
103 | .leaflet-overlay-pane svg {
104 | -moz-user-select: none;
105 | }
106 |
107 | .leaflet-pane { z-index: 400; }
108 |
109 | .leaflet-tile-pane { z-index: 200; }
110 | .leaflet-overlay-pane { z-index: 400; }
111 | .leaflet-shadow-pane { z-index: 500; }
112 | .leaflet-marker-pane { z-index: 600; }
113 | .leaflet-tooltip-pane { z-index: 650; }
114 | .leaflet-popup-pane { z-index: 700; }
115 |
116 | .leaflet-map-pane canvas { z-index: 100; }
117 | .leaflet-map-pane svg { z-index: 200; }
118 |
119 | .leaflet-vml-shape {
120 | width: 1px;
121 | height: 1px;
122 | }
123 | .lvml {
124 | behavior: url(#default#VML);
125 | display: inline-block;
126 | position: absolute;
127 | }
128 |
129 |
130 | /* control positioning */
131 |
132 | .leaflet-control {
133 | position: relative;
134 | z-index: 800;
135 | pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
136 | pointer-events: auto;
137 | }
138 | .leaflet-top,
139 | .leaflet-bottom {
140 | position: absolute;
141 | z-index: 1000;
142 | pointer-events: none;
143 | }
144 | .leaflet-top {
145 | top: 0;
146 | }
147 | .leaflet-right {
148 | right: 0;
149 | }
150 | .leaflet-bottom {
151 | bottom: 0;
152 | }
153 | .leaflet-left {
154 | left: 0;
155 | }
156 | .leaflet-control {
157 | float: left;
158 | clear: both;
159 | }
160 | .leaflet-right .leaflet-control {
161 | float: right;
162 | }
163 | .leaflet-top .leaflet-control {
164 | margin-top: 10px;
165 | }
166 | .leaflet-bottom .leaflet-control {
167 | margin-bottom: 10px;
168 | }
169 | .leaflet-left .leaflet-control {
170 | margin-left: 10px;
171 | }
172 | .leaflet-right .leaflet-control {
173 | margin-right: 10px;
174 | }
175 |
176 |
177 | /* zoom and fade animations */
178 |
179 | .leaflet-fade-anim .leaflet-popup {
180 | opacity: 0;
181 | -webkit-transition: opacity 0.2s linear;
182 | -moz-transition: opacity 0.2s linear;
183 | transition: opacity 0.2s linear;
184 | }
185 | .leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
186 | opacity: 1;
187 | }
188 | .leaflet-zoom-animated {
189 | -webkit-transform-origin: 0 0;
190 | -ms-transform-origin: 0 0;
191 | transform-origin: 0 0;
192 | }
193 | svg.leaflet-zoom-animated {
194 | will-change: transform;
195 | }
196 |
197 | .leaflet-zoom-anim .leaflet-zoom-animated {
198 | -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
199 | -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
200 | transition: transform 0.25s cubic-bezier(0,0,0.25,1);
201 | }
202 | .leaflet-zoom-anim .leaflet-tile,
203 | .leaflet-pan-anim .leaflet-tile {
204 | -webkit-transition: none;
205 | -moz-transition: none;
206 | transition: none;
207 | }
208 |
209 | .leaflet-zoom-anim .leaflet-zoom-hide {
210 | visibility: hidden;
211 | }
212 |
213 |
214 | /* cursors */
215 |
216 | .leaflet-interactive {
217 | cursor: pointer;
218 | }
219 | .leaflet-grab {
220 | cursor: -webkit-grab;
221 | cursor: -moz-grab;
222 | cursor: grab;
223 | }
224 | .leaflet-crosshair,
225 | .leaflet-crosshair .leaflet-interactive {
226 | cursor: crosshair;
227 | }
228 | .leaflet-popup-pane,
229 | .leaflet-control {
230 | cursor: auto;
231 | }
232 | .leaflet-dragging .leaflet-grab,
233 | .leaflet-dragging .leaflet-grab .leaflet-interactive,
234 | .leaflet-dragging .leaflet-marker-draggable {
235 | cursor: move;
236 | cursor: -webkit-grabbing;
237 | cursor: -moz-grabbing;
238 | cursor: grabbing;
239 | }
240 |
241 | /* marker & overlays interactivity */
242 | .leaflet-marker-icon,
243 | .leaflet-marker-shadow,
244 | .leaflet-image-layer,
245 | .leaflet-pane > svg path,
246 | .leaflet-tile-container {
247 | pointer-events: none;
248 | }
249 |
250 | .leaflet-marker-icon.leaflet-interactive,
251 | .leaflet-image-layer.leaflet-interactive,
252 | .leaflet-pane > svg path.leaflet-interactive,
253 | svg.leaflet-image-layer.leaflet-interactive path {
254 | pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
255 | pointer-events: auto;
256 | }
257 |
258 | /* visual tweaks */
259 |
260 | .leaflet-container {
261 | background: #ddd;
262 | outline-offset: 1px;
263 | }
264 | .leaflet-container a {
265 | color: #0078A8;
266 | }
267 | .leaflet-zoom-box {
268 | border: 2px dotted #38f;
269 | background: rgba(255,255,255,0.5);
270 | }
271 |
272 |
273 | /* general typography */
274 | .leaflet-container {
275 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
276 | font-size: 12px;
277 | font-size: 0.75rem;
278 | line-height: 1.5;
279 | }
280 |
281 |
282 | /* general toolbar styles */
283 |
284 | .leaflet-bar {
285 | box-shadow: 0 1px 5px rgba(0,0,0,0.65);
286 | border-radius: 4px;
287 | }
288 | .leaflet-bar a {
289 | background-color: #fff;
290 | border-bottom: 1px solid #ccc;
291 | width: 26px;
292 | height: 26px;
293 | line-height: 26px;
294 | display: block;
295 | text-align: center;
296 | text-decoration: none;
297 | color: black;
298 | }
299 | .leaflet-bar a,
300 | .leaflet-control-layers-toggle {
301 | background-position: 50% 50%;
302 | background-repeat: no-repeat;
303 | display: block;
304 | }
305 | .leaflet-bar a:hover,
306 | .leaflet-bar a:focus {
307 | background-color: #f4f4f4;
308 | }
309 | .leaflet-bar a:first-child {
310 | border-top-left-radius: 4px;
311 | border-top-right-radius: 4px;
312 | }
313 | .leaflet-bar a:last-child {
314 | border-bottom-left-radius: 4px;
315 | border-bottom-right-radius: 4px;
316 | border-bottom: none;
317 | }
318 | .leaflet-bar a.leaflet-disabled {
319 | cursor: default;
320 | background-color: #f4f4f4;
321 | color: #bbb;
322 | }
323 |
324 | .leaflet-touch .leaflet-bar a {
325 | width: 30px;
326 | height: 30px;
327 | line-height: 30px;
328 | }
329 | .leaflet-touch .leaflet-bar a:first-child {
330 | border-top-left-radius: 2px;
331 | border-top-right-radius: 2px;
332 | }
333 | .leaflet-touch .leaflet-bar a:last-child {
334 | border-bottom-left-radius: 2px;
335 | border-bottom-right-radius: 2px;
336 | }
337 |
338 | /* zoom control */
339 |
340 | .leaflet-control-zoom-in,
341 | .leaflet-control-zoom-out {
342 | font: bold 18px 'Lucida Console', Monaco, monospace;
343 | text-indent: 1px;
344 | }
345 |
346 | .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
347 | font-size: 22px;
348 | }
349 |
350 |
351 | /* layers control */
352 |
353 | .leaflet-control-layers {
354 | box-shadow: 0 1px 5px rgba(0,0,0,0.4);
355 | background: #fff;
356 | border-radius: 5px;
357 | }
358 | .leaflet-control-layers-toggle {
359 | background-image: url(images/layers.png);
360 | width: 36px;
361 | height: 36px;
362 | }
363 | .leaflet-retina .leaflet-control-layers-toggle {
364 | background-image: url(images/layers.png);
365 | background-size: 26px 26px;
366 | }
367 | .leaflet-touch .leaflet-control-layers-toggle {
368 | width: 44px;
369 | height: 44px;
370 | }
371 | .leaflet-control-layers .leaflet-control-layers-list,
372 | .leaflet-control-layers-expanded .leaflet-control-layers-toggle {
373 | display: none;
374 | }
375 | .leaflet-control-layers-expanded .leaflet-control-layers-list {
376 | display: block;
377 | position: relative;
378 | }
379 | .leaflet-control-layers-expanded {
380 | padding: 6px 10px 6px 6px;
381 | color: #333;
382 | background: #fff;
383 | }
384 | .leaflet-control-layers-scrollbar {
385 | overflow-y: scroll;
386 | overflow-x: hidden;
387 | padding-right: 5px;
388 | }
389 | .leaflet-control-layers-selector {
390 | margin-top: 2px;
391 | position: relative;
392 | top: 1px;
393 | }
394 | .leaflet-control-layers label {
395 | display: block;
396 | font-size: 13px;
397 | font-size: 1.08333em;
398 | }
399 | .leaflet-control-layers-separator {
400 | height: 0;
401 | border-top: 1px solid #ddd;
402 | margin: 5px -10px 5px -6px;
403 | }
404 |
405 | /* Default icon URLs */
406 | .leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
407 | background-image: url(images/marker-icon.png);
408 | }
409 |
410 |
411 | /* attribution and scale controls */
412 |
413 | .leaflet-container .leaflet-control-attribution {
414 | background: #fff;
415 | background: rgba(255, 255, 255, 0.8);
416 | margin: 0;
417 | }
418 | .leaflet-control-attribution,
419 | .leaflet-control-scale-line {
420 | padding: 0 5px;
421 | color: #333;
422 | line-height: 1.4;
423 | }
424 | .leaflet-control-attribution a {
425 | text-decoration: none;
426 | }
427 | .leaflet-control-attribution a:hover,
428 | .leaflet-control-attribution a:focus {
429 | text-decoration: underline;
430 | }
431 | .leaflet-attribution-flag {
432 | display: none !important;
433 | vertical-align: baseline !important;
434 | width: 1em;
435 | height: 0.6669em;
436 | }
437 | .leaflet-left .leaflet-control-scale {
438 | margin-left: 5px;
439 | }
440 | .leaflet-bottom .leaflet-control-scale {
441 | margin-bottom: 5px;
442 | }
443 | .leaflet-control-scale-line {
444 | border: 2px solid #777;
445 | border-top: none;
446 | line-height: 1.1;
447 | padding: 2px 5px 1px;
448 | white-space: nowrap;
449 | -moz-box-sizing: border-box;
450 | box-sizing: border-box;
451 | background: rgba(255, 255, 255, 0.8);
452 | text-shadow: 1px 1px #fff;
453 | }
454 | .leaflet-control-scale-line:not(:first-child) {
455 | border-top: 2px solid #777;
456 | border-bottom: none;
457 | margin-top: -2px;
458 | }
459 | .leaflet-control-scale-line:not(:first-child):not(:last-child) {
460 | border-bottom: 2px solid #777;
461 | }
462 |
463 | .leaflet-touch .leaflet-control-attribution,
464 | .leaflet-touch .leaflet-control-layers,
465 | .leaflet-touch .leaflet-bar {
466 | box-shadow: none;
467 | }
468 | .leaflet-touch .leaflet-control-layers,
469 | .leaflet-touch .leaflet-bar {
470 | border: 2px solid rgba(0,0,0,0.2);
471 | background-clip: padding-box;
472 | }
473 |
474 |
475 | /* popup */
476 |
477 | .leaflet-popup {
478 | position: absolute;
479 | text-align: center;
480 | margin-bottom: 20px;
481 | }
482 | .leaflet-popup-content-wrapper {
483 | padding: 1px;
484 | text-align: left;
485 | border-radius: 12px;
486 | }
487 | .leaflet-popup-content {
488 | margin: 13px 24px 13px 20px;
489 | line-height: 1.3;
490 | font-size: 13px;
491 | font-size: 1.08333em;
492 | min-height: 1px;
493 | }
494 | .leaflet-popup-content p {
495 | margin: 17px 0;
496 | margin: 1.3em 0;
497 | }
498 | .leaflet-popup-tip-container {
499 | width: 40px;
500 | height: 20px;
501 | position: absolute;
502 | left: 50%;
503 | margin-top: -1px;
504 | margin-left: -20px;
505 | overflow: hidden;
506 | pointer-events: none;
507 | }
508 | .leaflet-popup-tip {
509 | width: 17px;
510 | height: 17px;
511 | padding: 1px;
512 |
513 | margin: -10px auto 0;
514 | pointer-events: auto;
515 |
516 | -webkit-transform: rotate(45deg);
517 | -moz-transform: rotate(45deg);
518 | -ms-transform: rotate(45deg);
519 | transform: rotate(45deg);
520 | }
521 | .leaflet-popup-content-wrapper,
522 | .leaflet-popup-tip {
523 | background: white;
524 | color: #333;
525 | box-shadow: 0 3px 14px rgba(0,0,0,0.4);
526 | }
527 | .leaflet-container a.leaflet-popup-close-button {
528 | position: absolute;
529 | top: 0;
530 | right: 0;
531 | border: none;
532 | text-align: center;
533 | width: 24px;
534 | height: 24px;
535 | font: 16px/24px Tahoma, Verdana, sans-serif;
536 | color: #757575;
537 | text-decoration: none;
538 | background: transparent;
539 | }
540 | .leaflet-container a.leaflet-popup-close-button:hover,
541 | .leaflet-container a.leaflet-popup-close-button:focus {
542 | color: #585858;
543 | }
544 | .leaflet-popup-scrolled {
545 | overflow: auto;
546 | }
547 |
548 | .leaflet-oldie .leaflet-popup-content-wrapper {
549 | -ms-zoom: 1;
550 | }
551 | .leaflet-oldie .leaflet-popup-tip {
552 | width: 24px;
553 | margin: 0 auto;
554 |
555 | -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
556 | filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
557 | }
558 |
559 | .leaflet-oldie .leaflet-control-zoom,
560 | .leaflet-oldie .leaflet-control-layers,
561 | .leaflet-oldie .leaflet-popup-content-wrapper,
562 | .leaflet-oldie .leaflet-popup-tip {
563 | border: 1px solid #999;
564 | }
565 |
566 |
567 | /* div icon */
568 |
569 | .leaflet-div-icon {
570 | background: #fff;
571 | border: 1px solid #666;
572 | }
573 |
574 |
575 | /* Tooltip */
576 | /* Base styles for the element that has a tooltip */
577 | .leaflet-tooltip {
578 | position: absolute;
579 | padding: 6px;
580 | background-color: #fff;
581 | border: 1px solid #fff;
582 | border-radius: 3px;
583 | color: #222;
584 | white-space: nowrap;
585 | -webkit-user-select: none;
586 | -moz-user-select: none;
587 | -ms-user-select: none;
588 | user-select: none;
589 | pointer-events: none;
590 | box-shadow: 0 1px 3px rgba(0,0,0,0.4);
591 | }
592 | .leaflet-tooltip.leaflet-interactive {
593 | cursor: pointer;
594 | pointer-events: auto;
595 | }
596 | .leaflet-tooltip-top:before,
597 | .leaflet-tooltip-bottom:before,
598 | .leaflet-tooltip-left:before,
599 | .leaflet-tooltip-right:before {
600 | position: absolute;
601 | pointer-events: none;
602 | border: 6px solid transparent;
603 | background: transparent;
604 | content: "";
605 | }
606 |
607 | /* Directions */
608 |
609 | .leaflet-tooltip-bottom {
610 | margin-top: 6px;
611 | }
612 | .leaflet-tooltip-top {
613 | margin-top: -6px;
614 | }
615 | .leaflet-tooltip-bottom:before,
616 | .leaflet-tooltip-top:before {
617 | left: 50%;
618 | margin-left: -6px;
619 | }
620 | .leaflet-tooltip-top:before {
621 | bottom: 0;
622 | margin-bottom: -12px;
623 | border-top-color: #fff;
624 | }
625 | .leaflet-tooltip-bottom:before {
626 | top: 0;
627 | margin-top: -12px;
628 | margin-left: -6px;
629 | border-bottom-color: #fff;
630 | }
631 | .leaflet-tooltip-left {
632 | margin-left: -6px;
633 | }
634 | .leaflet-tooltip-right {
635 | margin-left: 6px;
636 | }
637 | .leaflet-tooltip-left:before,
638 | .leaflet-tooltip-right:before {
639 | top: 50%;
640 | margin-top: -6px;
641 | }
642 | .leaflet-tooltip-left:before {
643 | right: 0;
644 | margin-right: -12px;
645 | border-left-color: #fff;
646 | }
647 | .leaflet-tooltip-right:before {
648 | left: 0;
649 | margin-left: -12px;
650 | border-right-color: #fff;
651 | }
652 |
653 | /* Printing */
654 |
655 | @media print {
656 | /* Prevent printers from removing background-images of controls. */
657 | .leaflet-control {
658 | -webkit-print-color-adjust: exact;
659 | print-color-adjust: exact;
660 | }
661 | }
--------------------------------------------------------------------------------
/src/qtiles/tilingthread.py:
--------------------------------------------------------------------------------
1 | # ******************************************************************************
2 | #
3 | # QTiles
4 | # ---------------------------------------------------------
5 | # Generates tiles from QGIS project
6 | #
7 | # Copyright (C) 2012-2022 NextGIS (info@nextgis.com)
8 | #
9 | # This source is free software; you can redistribute it and/or modify it under
10 | # the terms of the GNU General Public License as published by the Free
11 | # Software Foundation, either version 2 of the License, or (at your option)
12 | # any later version.
13 | #
14 | # This code is distributed in the hope that it will be useful, but WITHOUT ANY
15 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 | # details.
18 | #
19 | # A copy of the GNU General Public License is available on the World Wide Web
20 | # at . You can also obtain it by writing
21 | # to the Free Software Foundation, 51 Franklin Street, Suite 500 Boston,
22 | # MA 02110-1335 USA.
23 | #
24 | # ******************************************************************************
25 | import json
26 | import time
27 | from string import Template
28 | from typing import List
29 |
30 | from qgis.core import (
31 | QgsMapLayer,
32 | QgsMapRendererCustomPainterJob,
33 | QgsMapSettings,
34 | QgsMessageLog,
35 | QgsProject,
36 | QgsRectangle,
37 | QgsScaleCalculator,
38 | )
39 | from qgis.PyQt.QtCore import (
40 | QFile,
41 | QIODevice,
42 | QMutex,
43 | Qt,
44 | QThread,
45 | pyqtSignal,
46 | )
47 | from qgis.PyQt.QtGui import QColor, QImage, QPainter
48 | from qgis.PyQt.QtWidgets import *
49 |
50 | from qtiles.qtiles_utils import create_viewer_directory
51 |
52 | from . import resources_rc # noqa: F401
53 | from .compat import (
54 | QGIS_VERSION_3,
55 | QgsCoordinateReferenceSystem,
56 | QgsCoordinateTransform,
57 | QgsMessageLogInfo,
58 | )
59 | from .tile import Tile
60 | from .writers import *
61 |
62 |
63 | def printQtilesLog(msg, level=QgsMessageLogInfo):
64 | QgsMessageLog.logMessage(msg, "QTiles", level)
65 |
66 |
67 | class TilingThread(QThread):
68 | """
69 | Background thread for generating map tiles.
70 | """
71 |
72 | rangeChanged = pyqtSignal(str, int)
73 | updateProgress = pyqtSignal()
74 | processFinished = pyqtSignal()
75 | processInterrupted = pyqtSignal()
76 |
77 | def __init__(
78 | self,
79 | tiles: List[Tile],
80 | layers: List[QgsMapLayer],
81 | extent: QgsRectangle,
82 | min_zoom: int,
83 | max_zoom: int,
84 | width: int,
85 | height: int,
86 | transp: int,
87 | quality: int,
88 | format: str,
89 | output_path: Path,
90 | root_dir: str,
91 | antialiasing: bool,
92 | tms_convention: bool,
93 | mbtiles_compression: bool,
94 | json_file: bool,
95 | overview: bool,
96 | map_url: bool,
97 | viewer: bool,
98 | ) -> None:
99 | """
100 | Initializes the TilingThread with the given parameters.
101 |
102 | :param tiles: A list of tiles to generate.
103 | :param layers: A list of map layers to render.
104 | :param extent: The geographical extent for tile generation.
105 | :param min_zoom: The minimum zoom level.
106 | :param max_zoom: The maximum zoom level.
107 | :param width: The width of each tile in pixels.
108 | :param height: The height of each tile in pixels.
109 | :param transp: The transparency level for tiles.
110 | :param quality: The quality level for image compression.
111 | :param format: The output format (e.g., PNG, JPG).
112 | :param output_path: The file path for saving the tiles.
113 | :param root_dir: The root directory for output files.
114 | :param antialiasing: Whether to enable antialiasing.
115 | :param tms_convention: Whether to use TMS naming convention.
116 | :param mbtiles_compression: Whether to enable MBTiles compression.
117 | :param json_file: Whether to generate a JSON metadata file.
118 | :param overview: Whether to generate an overview file.
119 | :param map_url: Whether to include a map URL in the output.
120 | :param viewer: Whether to generate a viewer for the tiles.
121 | """
122 | super().__init__()
123 | self.mutex = QMutex()
124 | self.confirmMutex = QMutex()
125 | self.stopMe = 0
126 | self.interrupted = False
127 | self.tiles = tiles
128 | self.layers = layers
129 | self.extent = extent
130 | self.min_zoom = min_zoom
131 | self.max_zoom = max_zoom
132 | self.output_path = output_path
133 | self.width = width
134 | if root_dir:
135 | self.root_dir = root_dir
136 | else:
137 | self.root_dir = "tileset_%s" % str(time.time()).split(".")[0]
138 | self.antialias = antialiasing
139 | self.tms_convention = tms_convention
140 | self.mbtiles_compression = mbtiles_compression
141 | self.format = format
142 | self.quality = quality
143 | self.json_file = json_file
144 | self.overview = overview
145 | self.mapurl = map_url
146 | self.viewer = viewer
147 |
148 | if self.output_path.is_dir():
149 | self.mode = "DIR"
150 | elif self.output_path.suffix.lower() == ".zip":
151 | self.mode = "ZIP"
152 | elif self.output_path.suffix.lower() == ".ngrc":
153 | self.mode = "NGM"
154 | elif self.output_path.suffix.lower() == ".mbtiles":
155 | self.mode = "MBTILES"
156 | self.tms_convention = True
157 |
158 | self.interrupted = False
159 | self.layersId = []
160 | for layer in self.layers:
161 | self.layersId.append(layer.id())
162 | myRed = QgsProject.instance().readNumEntry(
163 | "Gui", "/CanvasColorRedPart", 255
164 | )[0]
165 | myGreen = QgsProject.instance().readNumEntry(
166 | "Gui", "/CanvasColorGreenPart", 255
167 | )[0]
168 | myBlue = QgsProject.instance().readNumEntry(
169 | "Gui", "/CanvasColorBluePart", 255
170 | )[0]
171 | self.color = QColor(myRed, myGreen, myBlue, transp)
172 | image = QImage(
173 | width, height, QImage.Format.Format_ARGB32_Premultiplied
174 | )
175 | self.projector = QgsCoordinateTransform(
176 | QgsCoordinateReferenceSystem.fromEpsgId(4326),
177 | QgsCoordinateReferenceSystem.fromEpsgId(3395),
178 | )
179 | self.scaleCalc = QgsScaleCalculator()
180 | self.scaleCalc.setDpi(image.logicalDpiX())
181 | self.scaleCalc.setMapUnits(
182 | QgsCoordinateReferenceSystem.fromEpsgId(3395).mapUnits()
183 | )
184 | self.settings = QgsMapSettings()
185 | self.settings.setBackgroundColor(self.color)
186 |
187 | if not QGIS_VERSION_3:
188 | self.settings.setCrsTransformEnabled(True)
189 |
190 | self.settings.setOutputDpi(image.logicalDpiX())
191 | self.settings.setOutputImageFormat(
192 | QImage.Format.Format_ARGB32_Premultiplied
193 | )
194 | self.settings.setDestinationCrs(
195 | QgsCoordinateReferenceSystem.fromEpsgId(3395)
196 | )
197 | self.settings.setOutputSize(image.size())
198 |
199 | if QGIS_VERSION_3:
200 | self.settings.setLayers(self.layers)
201 | else:
202 | self.settings.setLayers(self.layersId)
203 |
204 | if not QGIS_VERSION_3:
205 | self.settings.setMapUnits(
206 | QgsCoordinateReferenceSystem.fromEpsgId(3395).mapUnits()
207 | )
208 |
209 | if self.antialias:
210 | self.settings.setFlag(QgsMapSettings.Antialiasing, True)
211 | else:
212 | self.settings.setFlag(QgsMapSettings.DrawLabeling, True)
213 |
214 | def run(self) -> None:
215 | """
216 | Starts the tile generation process in the background thread.
217 | The process renders tiles and writes them to the specified output format.
218 | If the tile count exceeds the threshold, the user is asked to confirm continuation.
219 | The method processes tiles sequentially until either
220 | all tiles are generated or the process is interrupted.
221 | """
222 | self.mutex.lock()
223 | self.stopMe = 0
224 | self.mutex.unlock()
225 | if self.mode == "DIR":
226 | self.writer = DirectoryWriter(self.output_path, self.root_dir)
227 | if self.mapurl:
228 | self.writeMapurlFile()
229 | if self.viewer:
230 | self.writeLeafletViewer()
231 | elif self.mode == "ZIP":
232 | self.writer = ZipWriter(self.output_path, self.root_dir)
233 | elif self.mode == "NGM":
234 | self.writer = NGMArchiveWriter(self.output_path, self.root_dir)
235 | elif self.mode == "MBTILES":
236 | self.writer = MBTilesWriter(
237 | self.output_path,
238 | self.root_dir,
239 | self.format,
240 | self.min_zoom,
241 | self.max_zoom,
242 | self.extent,
243 | self.mbtiles_compression,
244 | )
245 | if self.json_file:
246 | self.writeJsonFile()
247 | if self.overview:
248 | self.writeOverviewFile()
249 | self.rangeChanged.emit(self.tr("Searching tiles..."), 0)
250 |
251 | if self.interrupted:
252 | self.tiles.clear()
253 | self.processInterrupted.emit()
254 | return
255 |
256 | self.rangeChanged.emit(
257 | self.tr("Rendering: %v from %m (%p%)"), len(self.tiles)
258 | )
259 |
260 | self.confirmMutex.lock()
261 | if self.interrupted:
262 | self.processInterrupted.emit()
263 | return
264 |
265 | for tile in self.tiles:
266 | self.render(tile)
267 | self.updateProgress.emit()
268 | self.mutex.lock()
269 | s = self.stopMe
270 | self.mutex.unlock()
271 | if s == 1:
272 | self.interrupted = True
273 | break
274 |
275 | self.writer.finalize()
276 | if not self.interrupted:
277 | self.processFinished.emit()
278 | else:
279 | self.processInterrupted.emit()
280 |
281 | def stop(self) -> None:
282 | """
283 | Stops the tile generation process.
284 |
285 | This method sets a flag to interrupt the thread and halt the
286 | generation of remaining tiles.
287 | """
288 | self.mutex.lock()
289 | self.stopMe = 1
290 | self.mutex.unlock()
291 | QThread.wait(self)
292 |
293 | def writeJsonFile(self) -> None:
294 | """
295 | Writes a JSON metadata file that describes the tile set.
296 |
297 | The file contains information about the tile set,
298 | such as the format, zoom levels, and geographical bounds.
299 | """
300 | if self.mode == "DIR":
301 | file_path = self.output_path / f"{self.root_dir}.json"
302 | else:
303 | base_name = self.output_path.stem
304 | file_path = self.output_path.parent / f"{base_name}.json"
305 |
306 | info = {
307 | "name": self.root_dir,
308 | "format": self.format.lower(),
309 | "minZoom": self.min_zoom,
310 | "maxZoom": self.max_zoom,
311 | "bounds": str(self.extent.xMinimum())
312 | + ","
313 | + str(self.extent.yMinimum())
314 | + ","
315 | + str(self.extent.xMaximum())
316 | + ","
317 | + str(self.extent.yMaximum()),
318 | }
319 |
320 | with open(str(file_path), "w", encoding="utf-8") as json_file:
321 | json.dump(info, json_file)
322 |
323 | def writeOverviewFile(self) -> None:
324 | """
325 | Generates an overview image for the tile set.
326 |
327 | This image represents the entire geographical extent
328 | in a single image, useful for creating a preview of the tile set.
329 | """
330 | self.settings.setExtent(self.projector.transform(self.extent))
331 |
332 | image = QImage(self.settings.outputSize(), QImage.Format.Format_ARGB32)
333 | image.fill(Qt.GlobalColor.transparent)
334 |
335 | dpm = round(self.settings.outputDpi() / 25.4 * 1000)
336 | image.setDotsPerMeterX(dpm)
337 | image.setDotsPerMeterY(dpm)
338 |
339 | # job = QgsMapRendererSequentialJob(self.settings)
340 | # job.start()
341 | # job.waitForFinished()
342 | # image = job.renderedImage()
343 |
344 | painter = QPainter(image)
345 | job = QgsMapRendererCustomPainterJob(self.settings, painter)
346 | job.renderSynchronously()
347 | painter.end()
348 |
349 | if self.mode == "DIR":
350 | file_path = (
351 | self.output_path / f"{self.root_dir}.{self.format.lower()}"
352 | )
353 | else:
354 | base_name = self.output_path.stem
355 | file_path = (
356 | self.output_path.parent / f"{base_name}.{self.format.lower()}"
357 | )
358 |
359 | image.save(str(file_path), self.format, self.quality)
360 |
361 | def writeMapurlFile(self) -> None:
362 | """
363 | Writes a `.mapurl` file that includes details
364 | on how to access the tile server.
365 |
366 | The map URL file contains information about the tile format,
367 | zoom levels, and server convention (TMS or Google).
368 | """
369 | file_path = self.output_path / f"{self.root_dir}.mapurl"
370 | tileServer = "tms" if self.tms_convention else "google"
371 | with open(str(file_path), "w", encoding="utf-8") as mapurl:
372 | mapurl.write(
373 | "%s=%s\n" % ("url", self.root_dir + "/ZZZ/XXX/YYY.png")
374 | )
375 | mapurl.write("%s=%s\n" % ("minzoom", self.min_zoom))
376 | mapurl.write("%s=%s\n" % ("maxzoom", self.max_zoom))
377 | mapurl.write(
378 | "%s=%f %f\n"
379 | % (
380 | "center",
381 | self.extent.center().x(),
382 | self.extent.center().y(),
383 | )
384 | )
385 | mapurl.write("%s=%s\n" % ("type", tileServer))
386 |
387 | def writeLeafletViewer(self) -> None:
388 | """
389 | Writes an HTML file for a Leaflet viewer to visualize the generated tiles.
390 |
391 | The viewer allows users to interact
392 | with the tiles and navigate through the map.
393 | """
394 | template_file = QFile(":/plugins/qtiles/resources/viewer.html")
395 | if not template_file.open(QIODevice.OpenModeFlag.ReadOnly):
396 | return
397 |
398 | html = template_file.readAll().data().decode()
399 | template_file.close()
400 | viewer_template = MyTemplate(html)
401 |
402 | viewer_dir = self.output_path / f"{self.root_dir}_viewer"
403 |
404 | create_viewer_directory(viewer_dir)
405 |
406 | tiles_dir_relative = f"../{self.root_dir}"
407 | substitutions = {
408 | "tilesdir": tiles_dir_relative,
409 | "tilesext": self.format.lower(),
410 | "tilesetname": self.root_dir,
411 | "tms": "true" if self.tms_convention else "false",
412 | "centerx": self.extent.center().x(),
413 | "centery": self.extent.center().y(),
414 | "avgzoom": (self.max_zoom + self.min_zoom) / 2,
415 | "maxzoom": self.max_zoom,
416 | }
417 |
418 | output_html = viewer_template.substitute(substitutions)
419 | index_path = viewer_dir / "index.html"
420 | with open(str(index_path), "wb") as html_viewer:
421 | html_viewer.write(output_html.encode("utf-8"))
422 |
423 | def render(self, tile: Tile) -> None:
424 | """
425 | Renders a single tile based on the provided tile object.
426 |
427 | This method processes a tile by rendering it to an image,
428 | using map settings and transforms.
429 | """
430 | # scale = self.scaleCalc.calculate(
431 | # self.projector.transform(tile.toRectangle()), self.width)
432 |
433 | self.settings.setExtent(self.projector.transform(tile.toRectangle()))
434 |
435 | image = QImage(self.settings.outputSize(), QImage.Format.Format_ARGB32)
436 | image.fill(Qt.GlobalColor.transparent)
437 |
438 | dpm = round(self.settings.outputDpi() / 25.4 * 1000)
439 | image.setDotsPerMeterX(dpm)
440 | image.setDotsPerMeterY(dpm)
441 |
442 | # job = QgsMapRendererSequentialJob(self.settings)
443 | # job.start()
444 | # job.waitForFinished()
445 | # image = job.renderedImage()
446 |
447 | painter = QPainter(image)
448 | job = QgsMapRendererCustomPainterJob(self.settings, painter)
449 | job.renderSynchronously()
450 | painter.end()
451 | self.writer.writeTile(tile, image, self.format, self.quality)
452 |
453 |
454 | class MyTemplate(Template):
455 | """
456 | A subclass of Python's built-in Template class
457 | that uses a custom delimiter "@" for variable substitution.
458 |
459 | This class allows template substitution
460 | using the "@" symbol instead of the default "${}" delimiter.
461 | It is used to customize the rendering of template strings
462 | for generating HTML files, specifically in the context of creating
463 | a Leaflet viewer for the tile generation process.
464 | """
465 |
466 | delimiter = "@"
467 |
468 | def __init__(self, template_string: str) -> None:
469 | """
470 | Initializes the MyTemplate class with the provided template string.
471 |
472 | :param templateString: The template string to be processed.
473 | This string can contain placeholders that will be
474 | replaced with actual values during template substitution.
475 | """
476 | Template.__init__(self, template_string)
477 |
--------------------------------------------------------------------------------
/src/qtiles/i18n/qtiles_ru_RU.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AboutDialog
6 |
7 |
8 | Developers
9 | Разработчики
10 |
11 |
12 |
13 | Homepage
14 | Домашняя страница
15 |
16 |
17 |
18 | Please report bugs at
19 | Пожалуйста, сообщайте об ошибках в
20 |
21 |
22 |
23 | Video with an overview of the plugin
24 | Видео с обзором плагина
25 |
26 |
27 |
28 | Other helpful services by NextGIS
29 | Другие полезные сервисы от NextGIS
30 |
31 |
32 |
33 | Convenient up-to-date data extracts for any place in the world
34 | Удобная выборка актуальных данных из любой точки мира
35 |
36 |
37 |
38 | Fully featured Web GIS service
39 | Полнофункциональный Веб ГИС-сервис
40 |
41 |
42 |
43 | REPORT_END
44 |
45 |
46 |
47 |
48 | bugtracker
49 | багтрекер
50 |
51 |
52 |
53 | by NextGIS
54 | от NextGIS
55 |
56 |
57 |
58 | AboutDialogBase
59 |
60 |
61 | About {plugin_name}
62 | О модуле {plugin_name}
63 |
64 |
65 |
66 | Information
67 | Информация
68 |
69 |
70 |
71 | License
72 | Лицензия
73 |
74 |
75 |
76 | Components
77 | Компоненты
78 |
79 |
80 |
81 | Contributors
82 | Участники
83 |
84 |
85 |
86 | {plugin_name}
87 |
88 |
89 |
90 |
91 | Version {version}
92 | Версия {version}
93 |
94 |
95 |
96 | Get involved
97 | Присоединяйтесь
98 |
99 |
100 |
101 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
102 | <html><head><meta name="qrichtext" content="1" /><style type="text/css">
103 | p, li { white-space: pre-wrap; }
104 | </style></head><body style=" font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;">
105 | <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html>
106 |
107 |
108 |
109 |
110 | Dialog
111 |
112 |
113 | QTiles
114 |
115 |
116 |
117 |
118 | Output
119 | Результат
120 |
121 |
122 |
123 | Directory
124 | Каталог
125 |
126 |
127 |
128 | Extent
129 | Охват
130 |
131 |
132 |
133 | Canvas extent
134 | Текущий охват карты
135 |
136 |
137 |
138 | Full extent
139 | Полный охват
140 |
141 |
142 |
143 | Layer extent
144 | Охват слоя
145 |
146 |
147 |
148 | Zoom
149 | Масштаб
150 |
151 |
152 |
153 | Minimum zoom
154 | Минимальный масштаб
155 |
156 |
157 |
158 | Maximum zoom
159 | Максимальный масштаб
160 |
161 |
162 |
163 | Parameters
164 | Параметры
165 |
166 |
167 |
168 | Tile width
169 | Ширина тайла
170 |
171 |
172 |
173 | Lock 1:1 ratio
174 | Зафиксировать соотношение сторон 1:1
175 |
176 |
177 |
178 | Tile height
179 | Высота тайла
180 |
181 |
182 |
183 | Tileset name
184 | Название набора
185 |
186 |
187 |
188 | Make lines appear less jagged at the expence of some drawing performance
189 | Рисовать сглаженные линии (снижает скорость отрисовки)
190 |
191 |
192 |
193 | Write .mapurl file
194 | Создать файл .mapurl
195 |
196 |
197 |
198 | Write Leaflet-based viewer
199 | Создать просмотрщик на Leaflet
200 |
201 |
202 |
203 | Use TMS tiles convention (Slippy Map by default)
204 | Использовать спецификацию TMS (по умолчанию Slippy Map)
205 |
206 |
207 |
208 | File
209 | Файл
210 |
211 |
212 |
213 | Background transparency
214 | Прозрачность фона
215 |
216 |
217 |
218 | NGM
219 |
220 |
221 |
222 |
223 | Use MBTiles compression
224 | Использовать сжатие MBTiles
225 |
226 |
227 |
228 | Write .json metadata
229 | Сохранить метаданные в файл .json
230 |
231 |
232 |
233 | Write overview image file
234 | Создать файл обзорного изображения карты
235 |
236 |
237 |
238 | Format
239 | Формат
240 |
241 |
242 |
243 | Quality
244 | Качество
245 |
246 |
247 |
248 | (0-100)
249 |
250 |
251 |
252 |
253 | PNG
254 |
255 |
256 |
257 |
258 | JPG
259 |
260 |
261 |
262 |
263 | <a href="infoOutpuZip"><img src=":/plugins/qtiles/icons/info.png"/></a>
264 |
265 |
266 |
267 |
268 | Render tiles outside of layers extents (within combined extent)
269 | Рисовать тайлы вне границ слоёв, но в охвате
270 |
271 |
272 |
273 | ...
274 |
275 |
276 |
277 |
278 | ╮
279 |
280 |
281 |
282 |
283 | ╯
284 |
285 |
286 |
287 |
288 | QTiles
289 |
290 |
291 | Error
292 | Ошибка
293 |
294 |
295 |
296 | QTiles
297 |
298 |
299 |
300 |
301 | About QTiles...
302 | О QTiles...
303 |
304 |
305 |
306 | QGIS %s detected.
307 |
308 | Обнаружен QGIS %s.
309 |
310 |
311 |
312 |
313 | QTilesDialog
314 |
315 |
316 | No output
317 | Не указан путь
318 |
319 |
320 |
321 | Output path is not set. Please enter correct path and try again.
322 | Не указан путь назначения. Пожалуйста, введите правильный путь и попробуйте ещё раз.
323 |
324 |
325 |
326 | Layer not selected
327 | Слой не выбран
328 |
329 |
330 |
331 | Please select a layer and try again.
332 | Пожалуйста, выберите слой и попробуйте еще раз.
333 |
334 |
335 |
336 | Directory not empty
337 | Каталог не пуст
338 |
339 |
340 |
341 | Selected directory is not empty. Continue?
342 | Каталог назначения не пуст. Продолжить?
343 |
344 |
345 |
346 | Wrong zoom
347 | Неверный масштаб
348 |
349 |
350 |
351 | Maximum zoom value is lower than minimum. Please correct this and try again.
352 | Значение максимального масштаба меньше минимального. Пожалуйста, исправьте ошибку и попробуйте ещё раз.
353 |
354 |
355 |
356 | Cancel
357 | Отменить
358 |
359 |
360 |
361 | Close
362 | Закрыть
363 |
364 |
365 |
366 | Confirmation
367 | Подтверждение
368 |
369 |
370 |
371 | Estimate number of tiles more then %d! Continue?
372 | Оцениваемое количество тайлов больше %d! Продолжить?
373 |
374 |
375 |
376 | Save to file
377 | Сохранить файл
378 |
379 |
380 |
381 | ZIP archives (*.zip *.ZIP)
382 | ZIP архивы (*.zip *.ZIP)
383 |
384 |
385 |
386 | Save to directory
387 | Выбрать каталог
388 |
389 |
390 |
391 | MBTiles databases (*.mbtiles *.MBTILES)
392 | База MBTiles (*.mbtiles *.MBTILES)
393 |
394 |
395 |
396 | Output type info
397 | Вид результата
398 |
399 |
400 |
401 | Save tiles as Zip or MBTiles
402 | Сохранить тайлы как Zip архив или MBTiles
403 |
404 |
405 |
406 | Save tiles as directory structure
407 | Сохранить тайлы в дереве каталогов
408 |
409 |
410 |
411 | Run
412 | Запустить
413 |
414 |
415 |
416 | Prepare package for <a href='http://nextgis.ru/en/nextgis-mobile/'> NextGIS Mobile </a>
417 | Подготовить пакет для <a href='http://nextgis.ru/nextgis-mobile/'> NextGIS Mobile </a>
418 |
419 |
420 |
421 | TilingThread
422 |
423 |
424 | Searching tiles...
425 | Поиск тайлов...
426 |
427 |
428 |
429 | Rendering: %v from %m (%p%)
430 | Отрисовка: %v из %m (%p%)
431 |
432 |
433 |
434 |
--------------------------------------------------------------------------------
/src/qtiles/mbutils.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | # MBUtil: a tool for MBTiles files
4 | # Supports importing, exporting, and more
5 | #
6 | # (c) Development Seed 2012
7 | # Licensed under BSD
8 |
9 | # for additional reference on schema see:
10 | # https://github.com/mapbox/node-mbtiles/blob/master/lib/schema.sql
11 |
12 | import json
13 | import logging
14 | import os
15 | import re
16 | import sqlite3
17 | import sys
18 | import time
19 | import uuid
20 | import zlib
21 |
22 | logger = logging.getLogger(__name__)
23 |
24 |
25 | def flip_y(zoom, y):
26 | return (2**zoom - 1) - y
27 |
28 |
29 | def mbtiles_setup(cur):
30 | cur.execute("""
31 | create table tiles (
32 | zoom_level integer,
33 | tile_column integer,
34 | tile_row integer,
35 | tile_data blob);
36 | """)
37 | cur.execute("""create table metadata
38 | (name text, value text);""")
39 | cur.execute("""CREATE TABLE grids (zoom_level integer, tile_column integer,
40 | tile_row integer, grid blob);""")
41 | cur.execute("""CREATE TABLE grid_data (zoom_level integer, tile_column
42 | integer, tile_row integer, key_name text, key_json text);""")
43 | cur.execute("""create unique index name on metadata (name);""")
44 | cur.execute("""create unique index tile_index on tiles
45 | (zoom_level, tile_column, tile_row);""")
46 |
47 |
48 | def mbtiles_connect(mbtiles_file, silent):
49 | try:
50 | con = sqlite3.connect(mbtiles_file)
51 | return con
52 | except Exception as e:
53 | if not silent:
54 | logger.error("Could not connect to database")
55 | logger.exception(e)
56 | sys.exit(1)
57 |
58 |
59 | def optimize_connection(cur):
60 | cur.execute("""PRAGMA synchronous=0""")
61 | cur.execute("""PRAGMA locking_mode=EXCLUSIVE""")
62 | cur.execute("""PRAGMA journal_mode=DELETE""")
63 |
64 |
65 | def compression_prepare(cur, silent):
66 | if not silent:
67 | logger.debug("Prepare database compression.")
68 | cur.execute("""
69 | CREATE TABLE if not exists images (
70 | tile_data blob,
71 | tile_id integer);
72 | """)
73 | cur.execute("""
74 | CREATE TABLE if not exists map (
75 | zoom_level integer,
76 | tile_column integer,
77 | tile_row integer,
78 | tile_id integer);
79 | """)
80 |
81 |
82 | def optimize_database(cur, silent):
83 | if not silent:
84 | logger.debug("analyzing db")
85 | cur.execute("""ANALYZE;""")
86 | if not silent:
87 | logger.debug("cleaning db")
88 |
89 | # Workaround for python>=3.6.0,python<3.6.2
90 | # https://bugs.python.org/issue28518
91 | cur.isolation_level = None
92 | cur.execute("""VACUUM;""")
93 | cur.isolation_level = "" # reset default value of isolation_level
94 |
95 |
96 | def compression_do(cur, con, chunk, silent):
97 | if not silent:
98 | logger.debug("Making database compression.")
99 | overlapping = 0
100 | unique = 0
101 | total = 0
102 | cur.execute("select count(zoom_level) from tiles")
103 | res = cur.fetchone()
104 | total_tiles = res[0]
105 | last_id = 0
106 | if not silent:
107 | logging.debug("%d total tiles to fetch" % total_tiles)
108 | for i in range(total_tiles // chunk + 1):
109 | if not silent:
110 | logging.debug("%d / %d rounds done" % (i, (total_tiles / chunk)))
111 | ids = []
112 | files = []
113 | start = time.time()
114 | cur.execute(
115 | """select zoom_level, tile_column, tile_row, tile_data
116 | from tiles where rowid > ? and rowid <= ?""",
117 | ((i * chunk), ((i + 1) * chunk)),
118 | )
119 | if not silent:
120 | logger.debug("select: %s" % (time.time() - start))
121 | rows = cur.fetchall()
122 | for r in rows:
123 | total = total + 1
124 | if r[3] in files:
125 | overlapping = overlapping + 1
126 | start = time.time()
127 | query = """insert into map
128 | (zoom_level, tile_column, tile_row, tile_id)
129 | values (?, ?, ?, ?)"""
130 | if not silent:
131 | logger.debug("insert: %s" % (time.time() - start))
132 | cur.execute(query, (r[0], r[1], r[2], ids[files.index(r[3])]))
133 | else:
134 | unique = unique + 1
135 | last_id += 1
136 |
137 | ids.append(last_id)
138 | files.append(r[3])
139 |
140 | start = time.time()
141 | query = """insert into images
142 | (tile_id, tile_data)
143 | values (?, ?)"""
144 | cur.execute(query, (str(last_id), sqlite3.Binary(r[3])))
145 | if not silent:
146 | logger.debug(
147 | "insert into images: %s" % (time.time() - start)
148 | )
149 | start = time.time()
150 | query = """insert into map
151 | (zoom_level, tile_column, tile_row, tile_id)
152 | values (?, ?, ?, ?)"""
153 | cur.execute(query, (r[0], r[1], r[2], last_id))
154 | if not silent:
155 | logger.debug("insert into map: %s" % (time.time() - start))
156 | con.commit()
157 |
158 |
159 | def compression_finalize(cur, con, silent):
160 | if not silent:
161 | logger.debug("Finalizing database compression.")
162 | cur.execute("""drop table tiles;""")
163 | cur.execute("""create view tiles as
164 | select map.zoom_level as zoom_level,
165 | map.tile_column as tile_column,
166 | map.tile_row as tile_row,
167 | images.tile_data as tile_data FROM
168 | map JOIN images on images.tile_id = map.tile_id;""")
169 | cur.execute("""
170 | CREATE UNIQUE INDEX map_index on map
171 | (zoom_level, tile_column, tile_row);""")
172 | cur.execute("""
173 | CREATE UNIQUE INDEX images_id on images
174 | (tile_id);""")
175 |
176 | # Workaround for python>=3.6.0,python<3.6.2
177 | # https://bugs.python.org/issue28518
178 | con.isolation_level = None
179 | cur.execute("""vacuum;""")
180 | con.isolation_level = "" # reset default value of isolation_level
181 |
182 | cur.execute("""analyze;""")
183 |
184 |
185 | def get_dirs(path):
186 | return [
187 | name
188 | for name in os.listdir(path)
189 | if os.path.isdir(os.path.join(path, name))
190 | ]
191 |
192 |
193 | def disk_to_mbtiles(directory_path, mbtiles_file, **kwargs):
194 | silent = kwargs.get("silent")
195 |
196 | if not silent:
197 | logger.info("Importing disk to MBTiles")
198 | logger.debug("%s --> %s" % (directory_path, mbtiles_file))
199 |
200 | con = mbtiles_connect(mbtiles_file, silent)
201 | cur = con.cursor()
202 | optimize_connection(cur)
203 | mbtiles_setup(cur)
204 | # ~ image_format = 'png'
205 | image_format = kwargs.get("format", "png")
206 |
207 | try:
208 | metadata = json.load(
209 | open(os.path.join(directory_path, "metadata.json"), "r")
210 | )
211 | image_format = kwargs.get("format")
212 | for name, value in metadata.items():
213 | cur.execute(
214 | "insert into metadata (name, value) values (?, ?)",
215 | (name, value),
216 | )
217 | if not silent:
218 | logger.info("metadata from metadata.json restored")
219 | except IOError:
220 | if not silent:
221 | logger.warning("metadata.json not found")
222 |
223 | count = 0
224 | start_time = time.time()
225 |
226 | for zoom_dir in get_dirs(directory_path):
227 | if kwargs.get("scheme") == "ags":
228 | if not "L" in zoom_dir:
229 | if not silent:
230 | logger.warning(
231 | "You appear to be using an ags scheme on an non-arcgis Server cache."
232 | )
233 | z = int(zoom_dir.replace("L", ""))
234 | elif kwargs.get("scheme") == "gwc":
235 | z = int(zoom_dir[-2:])
236 | else:
237 | if "L" in zoom_dir:
238 | if not silent:
239 | logger.warning(
240 | "You appear to be using a %s scheme on an arcgis Server cache. Try using --scheme=ags instead"
241 | % kwargs.get("scheme")
242 | )
243 | z = int(zoom_dir)
244 | for row_dir in get_dirs(os.path.join(directory_path, zoom_dir)):
245 | if kwargs.get("scheme") == "ags":
246 | y = flip_y(z, int(row_dir.replace("R", ""), 16))
247 | elif kwargs.get("scheme") == "gwc":
248 | pass
249 | elif kwargs.get("scheme") == "zyx":
250 | y = flip_y(int(z), int(row_dir))
251 | else:
252 | x = int(row_dir)
253 | for current_file in os.listdir(
254 | os.path.join(directory_path, zoom_dir, row_dir)
255 | ):
256 | if current_file == ".DS_Store" and not silent:
257 | logger.warning(
258 | "Your OS is MacOS,and the .DS_Store file will be ignored."
259 | )
260 | else:
261 | file_name, ext = current_file.split(".", 1)
262 | f = open(
263 | os.path.join(
264 | directory_path, zoom_dir, row_dir, current_file
265 | ),
266 | "rb",
267 | )
268 | file_content = f.read()
269 | f.close()
270 | if kwargs.get("scheme") == "xyz":
271 | y = flip_y(int(z), int(file_name))
272 | elif kwargs.get("scheme") == "ags":
273 | x = int(file_name.replace("C", ""), 16)
274 | elif kwargs.get("scheme") == "gwc":
275 | x, y = file_name.split("_")
276 | x = int(x)
277 | y = int(y)
278 | elif kwargs.get("scheme") == "zyx":
279 | x = int(file_name)
280 | else:
281 | y = int(file_name)
282 |
283 | if ext == image_format:
284 | if not silent:
285 | logger.debug(
286 | " Read tile from Zoom (z): %i\tCol (x): %i\tRow (y): %i"
287 | % (z, x, y)
288 | )
289 | cur.execute(
290 | """insert into tiles (zoom_level,
291 | tile_column, tile_row, tile_data) values
292 | (?, ?, ?, ?);""",
293 | (z, x, y, sqlite3.Binary(file_content)),
294 | )
295 | count = count + 1
296 | if (count % 100) == 0 and not silent:
297 | logger.info(
298 | " %s tiles inserted (%d tiles/sec)"
299 | % (count, count / (time.time() - start_time))
300 | )
301 | elif ext == "grid.json":
302 | if not silent:
303 | logger.debug(
304 | " Read grid from Zoom (z): %i\tCol (x): %i\tRow (y): %i"
305 | % (z, x, y)
306 | )
307 | # Remove potential callback with regex
308 | file_content = file_content.decode("utf-8")
309 | has_callback = re.match(
310 | r"[\w\s=+-/]+\(({(.|\n)*})\);?", file_content
311 | )
312 | if has_callback:
313 | file_content = has_callback.group(1)
314 | utfgrid = json.loads(file_content)
315 |
316 | data = utfgrid.pop("data")
317 | compressed = zlib.compress(
318 | json.dumps(utfgrid).encode()
319 | )
320 | cur.execute(
321 | """insert into grids (zoom_level, tile_column, tile_row, grid) values (?, ?, ?, ?) """,
322 | (z, x, y, sqlite3.Binary(compressed)),
323 | )
324 | grid_keys = [k for k in utfgrid["keys"] if k != ""]
325 | for key_name in grid_keys:
326 | key_json = data[key_name]
327 | cur.execute(
328 | """insert into grid_data (zoom_level, tile_column, tile_row, key_name, key_json) values (?, ?, ?, ?, ?);""",
329 | (z, x, y, key_name, json.dumps(key_json)),
330 | )
331 |
332 | if not silent:
333 | logger.debug("tiles (and grids) inserted.")
334 |
335 | if kwargs.get("compression", False):
336 | compression_prepare(cur, silent)
337 | compression_do(cur, con, 256, silent)
338 | compression_finalize(cur, con, silent)
339 |
340 | optimize_database(con, silent)
341 |
342 |
343 | def mbtiles_metadata_to_disk(mbtiles_file, **kwargs):
344 | silent = kwargs.get("silent")
345 | if not silent:
346 | logger.debug("Exporting MBTiles metatdata from %s" % (mbtiles_file))
347 | con = mbtiles_connect(mbtiles_file, silent)
348 | metadata = dict(
349 | con.execute("select name, value from metadata;").fetchall()
350 | )
351 | if not silent:
352 | logger.debug(json.dumps(metadata, indent=2))
353 |
354 |
355 | def mbtiles_to_disk(mbtiles_file, directory_path, **kwargs):
356 | silent = kwargs.get("silent")
357 | if not silent:
358 | logger.debug("Exporting MBTiles to disk")
359 | logger.debug("%s --> %s" % (mbtiles_file, directory_path))
360 | con = mbtiles_connect(mbtiles_file, silent)
361 | os.mkdir("%s" % directory_path)
362 | metadata = dict(
363 | con.execute("select name, value from metadata;").fetchall()
364 | )
365 | json.dump(
366 | metadata,
367 | open(os.path.join(directory_path, "metadata.json"), "w"),
368 | indent=4,
369 | )
370 | count = con.execute("select count(zoom_level) from tiles;").fetchone()[0]
371 | done = 0
372 | base_path = directory_path
373 | if not os.path.isdir(base_path):
374 | os.makedirs(base_path)
375 |
376 | # if interactivity
377 | formatter = metadata.get("formatter")
378 | if formatter:
379 | layer_json = os.path.join(base_path, "layer.json")
380 | formatter_json = {"formatter": formatter}
381 | open(layer_json, "w").write(json.dumps(formatter_json))
382 |
383 | tiles = con.execute(
384 | "select zoom_level, tile_column, tile_row, tile_data from tiles;"
385 | )
386 | t = tiles.fetchone()
387 | while t:
388 | z = t[0]
389 | x = t[1]
390 | y = t[2]
391 | if kwargs.get("scheme") == "xyz":
392 | y = flip_y(z, y)
393 | if not silent:
394 | logger.debug("flipping")
395 | tile_dir = os.path.join(base_path, str(z), str(x))
396 | elif kwargs.get("scheme") == "wms":
397 | tile_dir = os.path.join(
398 | base_path,
399 | "%02d" % (z),
400 | "%03d" % (int(x) / 1000000),
401 | "%03d" % ((int(x) / 1000) % 1000),
402 | "%03d" % (int(x) % 1000),
403 | "%03d" % (int(y) / 1000000),
404 | "%03d" % ((int(y) / 1000) % 1000),
405 | )
406 | else:
407 | tile_dir = os.path.join(base_path, str(z), str(x))
408 | if not os.path.isdir(tile_dir):
409 | os.makedirs(tile_dir)
410 | if kwargs.get("scheme") == "wms":
411 | tile = os.path.join(
412 | tile_dir,
413 | "%03d.%s" % (int(y) % 1000, kwargs.get("format", "png")),
414 | )
415 | else:
416 | tile = os.path.join(
417 | tile_dir, "%s.%s" % (y, kwargs.get("format", "png"))
418 | )
419 | f = open(tile, "wb")
420 | f.write(t[3])
421 | f.close()
422 | done = done + 1
423 | if not silent:
424 | logger.info("%s / %s tiles exported" % (done, count))
425 | t = tiles.fetchone()
426 |
427 | # grids
428 | callback = kwargs.get("callback")
429 | done = 0
430 | try:
431 | count = con.execute("select count(zoom_level) from grids;").fetchone()[
432 | 0
433 | ]
434 | grids = con.execute(
435 | "select zoom_level, tile_column, tile_row, grid from grids;"
436 | )
437 | g = grids.fetchone()
438 | except sqlite3.OperationalError:
439 | g = None # no grids table
440 | while g:
441 | zoom_level = g[0] # z
442 | tile_column = g[1] # x
443 | y = g[2] # y
444 | grid_data_cursor = con.execute(
445 | """select key_name, key_json FROM
446 | grid_data WHERE
447 | zoom_level = %(zoom_level)d and
448 | tile_column = %(tile_column)d and
449 | tile_row = %(y)d;"""
450 | % locals()
451 | )
452 | if kwargs.get("scheme") == "xyz":
453 | y = flip_y(zoom_level, y)
454 | grid_dir = os.path.join(base_path, str(zoom_level), str(tile_column))
455 | if not os.path.isdir(grid_dir):
456 | os.makedirs(grid_dir)
457 | grid = os.path.join(grid_dir, "%s.grid.json" % (y))
458 | f = open(grid, "w")
459 | grid_json = json.loads(zlib.decompress(g[3]).decode("utf-8"))
460 | # join up with the grid 'data' which is in pieces when stored in mbtiles file
461 | grid_data = grid_data_cursor.fetchone()
462 | data = {}
463 | while grid_data:
464 | data[grid_data[0]] = json.loads(grid_data[1])
465 | grid_data = grid_data_cursor.fetchone()
466 | grid_json["data"] = data
467 | if callback in (None, "", "false", "null"):
468 | f.write(json.dumps(grid_json))
469 | else:
470 | f.write("%s(%s);" % (callback, json.dumps(grid_json)))
471 | f.close()
472 | done = done + 1
473 | if not silent:
474 | logger.info("%s / %s grids exported" % (done, count))
475 | g = grids.fetchone()
476 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 2, June 1991
3 |
4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
6 | Everyone is permitted to copy and distribute verbatim copies
7 | of this license document, but changing it is not allowed.
8 |
9 | Preamble
10 |
11 | The licenses for most software are designed to take away your
12 | freedom to share and change it. By contrast, the GNU General Public
13 | License is intended to guarantee your freedom to share and change free
14 | software--to make sure the software is free for all its users. This
15 | General Public License applies to most of the Free Software
16 | Foundation's software and to any other program whose authors commit to
17 | using it. (Some other Free Software Foundation software is covered by
18 | the GNU Lesser General Public License instead.) You can apply it to
19 | your programs, too.
20 |
21 | When we speak of free software, we are referring to freedom, not
22 | price. Our General Public Licenses are designed to make sure that you
23 | have the freedom to distribute copies of free software (and charge for
24 | this service if you wish), that you receive source code or can get it
25 | if you want it, that you can change the software or use pieces of it
26 | in new free programs; and that you know you can do these things.
27 |
28 | To protect your rights, we need to make restrictions that forbid
29 | anyone to deny you these rights or to ask you to surrender the rights.
30 | These restrictions translate to certain responsibilities for you if you
31 | distribute copies of the software, or if you modify it.
32 |
33 | For example, if you distribute copies of such a program, whether
34 | gratis or for a fee, you must give the recipients all the rights that
35 | you have. You must make sure that they, too, receive or can get the
36 | source code. And you must show them these terms so they know their
37 | rights.
38 |
39 | We protect your rights with two steps: (1) copyright the software, and
40 | (2) offer you this license which gives you legal permission to copy,
41 | distribute and/or modify the software.
42 |
43 | Also, for each author's protection and ours, we want to make certain
44 | that everyone understands that there is no warranty for this free
45 | software. If the software is modified by someone else and passed on, we
46 | want its recipients to know that what they have is not the original, so
47 | that any problems introduced by others will not reflect on the original
48 | authors' reputations.
49 |
50 | Finally, any free program is threatened constantly by software
51 | patents. We wish to avoid the danger that redistributors of a free
52 | program will individually obtain patent licenses, in effect making the
53 | program proprietary. To prevent this, we have made it clear that any
54 | patent must be licensed for everyone's free use or not licensed at all.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | GNU GENERAL PUBLIC LICENSE
60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
61 |
62 | 0. This License applies to any program or other work which contains
63 | a notice placed by the copyright holder saying it may be distributed
64 | under the terms of this General Public License. The "Program", below,
65 | refers to any such program or work, and a "work based on the Program"
66 | means either the Program or any derivative work under copyright law:
67 | that is to say, a work containing the Program or a portion of it,
68 | either verbatim or with modifications and/or translated into another
69 | language. (Hereinafter, translation is included without limitation in
70 | the term "modification".) Each licensee is addressed as "you".
71 |
72 | Activities other than copying, distribution and modification are not
73 | covered by this License; they are outside its scope. The act of
74 | running the Program is not restricted, and the output from the Program
75 | is covered only if its contents constitute a work based on the
76 | Program (independent of having been made by running the Program).
77 | Whether that is true depends on what the Program does.
78 |
79 | 1. You may copy and distribute verbatim copies of the Program's
80 | source code as you receive it, in any medium, provided that you
81 | conspicuously and appropriately publish on each copy an appropriate
82 | copyright notice and disclaimer of warranty; keep intact all the
83 | notices that refer to this License and to the absence of any warranty;
84 | and give any other recipients of the Program a copy of this License
85 | along with the Program.
86 |
87 | You may charge a fee for the physical act of transferring a copy, and
88 | you may at your option offer warranty protection in exchange for a fee.
89 |
90 | 2. You may modify your copy or copies of the Program or any portion
91 | of it, thus forming a work based on the Program, and copy and
92 | distribute such modifications or work under the terms of Section 1
93 | above, provided that you also meet all of these conditions:
94 |
95 | a) You must cause the modified files to carry prominent notices
96 | stating that you changed the files and the date of any change.
97 |
98 | b) You must cause any work that you distribute or publish, that in
99 | whole or in part contains or is derived from the Program or any
100 | part thereof, to be licensed as a whole at no charge to all third
101 | parties under the terms of this License.
102 |
103 | c) If the modified program normally reads commands interactively
104 | when run, you must cause it, when started running for such
105 | interactive use in the most ordinary way, to print or display an
106 | announcement including an appropriate copyright notice and a
107 | notice that there is no warranty (or else, saying that you provide
108 | a warranty) and that users may redistribute the program under
109 | these conditions, and telling the user how to view a copy of this
110 | License. (Exception: if the Program itself is interactive but
111 | does not normally print such an announcement, your work based on
112 | the Program is not required to print an announcement.)
113 |
114 | These requirements apply to the modified work as a whole. If
115 | identifiable sections of that work are not derived from the Program,
116 | and can be reasonably considered independent and separate works in
117 | themselves, then this License, and its terms, do not apply to those
118 | sections when you distribute them as separate works. But when you
119 | distribute the same sections as part of a whole which is a work based
120 | on the Program, the distribution of the whole must be on the terms of
121 | this License, whose permissions for other licensees extend to the
122 | entire whole, and thus to each and every part regardless of who wrote it.
123 |
124 | Thus, it is not the intent of this section to claim rights or contest
125 | your rights to work written entirely by you; rather, the intent is to
126 | exercise the right to control the distribution of derivative or
127 | collective works based on the Program.
128 |
129 | In addition, mere aggregation of another work not based on the Program
130 | with the Program (or with a work based on the Program) on a volume of
131 | a storage or distribution medium does not bring the other work under
132 | the scope of this License.
133 |
134 | 3. You may copy and distribute the Program (or a work based on it,
135 | under Section 2) in object code or executable form under the terms of
136 | Sections 1 and 2 above provided that you also do one of the following:
137 |
138 | a) Accompany it with the complete corresponding machine-readable
139 | source code, which must be distributed under the terms of Sections
140 | 1 and 2 above on a medium customarily used for software interchange; or,
141 |
142 | b) Accompany it with a written offer, valid for at least three
143 | years, to give any third party, for a charge no more than your
144 | cost of physically performing source distribution, a complete
145 | machine-readable copy of the corresponding source code, to be
146 | distributed under the terms of Sections 1 and 2 above on a medium
147 | customarily used for software interchange; or,
148 |
149 | c) Accompany it with the information you received as to the offer
150 | to distribute corresponding source code. (This alternative is
151 | allowed only for noncommercial distribution and only if you
152 | received the program in object code or executable form with such
153 | an offer, in accord with Subsection b above.)
154 |
155 | The source code for a work means the preferred form of the work for
156 | making modifications to it. For an executable work, complete source
157 | code means all the source code for all modules it contains, plus any
158 | associated interface definition files, plus the scripts used to
159 | control compilation and installation of the executable. However, as a
160 | special exception, the source code distributed need not include
161 | anything that is normally distributed (in either source or binary
162 | form) with the major components (compiler, kernel, and so on) of the
163 | operating system on which the executable runs, unless that component
164 | itself accompanies the executable.
165 |
166 | If distribution of executable or object code is made by offering
167 | access to copy from a designated place, then offering equivalent
168 | access to copy the source code from the same place counts as
169 | distribution of the source code, even though third parties are not
170 | compelled to copy the source along with the object code.
171 |
172 | 4. You may not copy, modify, sublicense, or distribute the Program
173 | except as expressly provided under this License. Any attempt
174 | otherwise to copy, modify, sublicense or distribute the Program is
175 | void, and will automatically terminate your rights under this License.
176 | However, parties who have received copies, or rights, from you under
177 | this License will not have their licenses terminated so long as such
178 | parties remain in full compliance.
179 |
180 | 5. You are not required to accept this License, since you have not
181 | signed it. However, nothing else grants you permission to modify or
182 | distribute the Program or its derivative works. These actions are
183 | prohibited by law if you do not accept this License. Therefore, by
184 | modifying or distributing the Program (or any work based on the
185 | Program), you indicate your acceptance of this License to do so, and
186 | all its terms and conditions for copying, distributing or modifying
187 | the Program or works based on it.
188 |
189 | 6. Each time you redistribute the Program (or any work based on the
190 | Program), the recipient automatically receives a license from the
191 | original licensor to copy, distribute or modify the Program subject to
192 | these terms and conditions. You may not impose any further
193 | restrictions on the recipients' exercise of the rights granted herein.
194 | You are not responsible for enforcing compliance by third parties to
195 | this License.
196 |
197 | 7. If, as a consequence of a court judgment or allegation of patent
198 | infringement or for any other reason (not limited to patent issues),
199 | conditions are imposed on you (whether by court order, agreement or
200 | otherwise) that contradict the conditions of this License, they do not
201 | excuse you from the conditions of this License. If you cannot
202 | distribute so as to satisfy simultaneously your obligations under this
203 | License and any other pertinent obligations, then as a consequence you
204 | may not distribute the Program at all. For example, if a patent
205 | license would not permit royalty-free redistribution of the Program by
206 | all those who receive copies directly or indirectly through you, then
207 | the only way you could satisfy both it and this License would be to
208 | refrain entirely from distribution of the Program.
209 |
210 | If any portion of this section is held invalid or unenforceable under
211 | any particular circumstance, the balance of the section is intended to
212 | apply and the section as a whole is intended to apply in other
213 | circumstances.
214 |
215 | It is not the purpose of this section to induce you to infringe any
216 | patents or other property right claims or to contest validity of any
217 | such claims; this section has the sole purpose of protecting the
218 | integrity of the free software distribution system, which is
219 | implemented by public license practices. Many people have made
220 | generous contributions to the wide range of software distributed
221 | through that system in reliance on consistent application of that
222 | system; it is up to the author/donor to decide if he or she is willing
223 | to distribute software through any other system and a licensee cannot
224 | impose that choice.
225 |
226 | This section is intended to make thoroughly clear what is believed to
227 | be a consequence of the rest of this License.
228 |
229 | 8. If the distribution and/or use of the Program is restricted in
230 | certain countries either by patents or by copyrighted interfaces, the
231 | original copyright holder who places the Program under this License
232 | may add an explicit geographical distribution limitation excluding
233 | those countries, so that distribution is permitted only in or among
234 | countries not thus excluded. In such case, this License incorporates
235 | the limitation as if written in the body of this License.
236 |
237 | 9. The Free Software Foundation may publish revised and/or new versions
238 | of the General Public License from time to time. Such new versions will
239 | be similar in spirit to the present version, but may differ in detail to
240 | address new problems or concerns.
241 |
242 | Each version is given a distinguishing version number. If the Program
243 | specifies a version number of this License which applies to it and "any
244 | later version", you have the option of following the terms and conditions
245 | either of that version or of any later version published by the Free
246 | Software Foundation. If the Program does not specify a version number of
247 | this License, you may choose any version ever published by the Free Software
248 | Foundation.
249 |
250 | 10. If you wish to incorporate parts of the Program into other free
251 | programs whose distribution conditions are different, write to the author
252 | to ask for permission. For software which is copyrighted by the Free
253 | Software Foundation, write to the Free Software Foundation; we sometimes
254 | make exceptions for this. Our decision will be guided by the two goals
255 | of preserving the free status of all derivatives of our free software and
256 | of promoting the sharing and reuse of software generally.
257 |
258 | NO WARRANTY
259 |
260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
268 | REPAIR OR CORRECTION.
269 |
270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
278 | POSSIBILITY OF SUCH DAMAGES.
279 |
280 | END OF TERMS AND CONDITIONS
281 |
282 | How to Apply These Terms to Your New Programs
283 |
284 | If you develop a new program, and you want it to be of the greatest
285 | possible use to the public, the best way to achieve this is to make it
286 | free software which everyone can redistribute and change under these terms.
287 |
288 | To do so, attach the following notices to the program. It is safest
289 | to attach them to the start of each source file to most effectively
290 | convey the exclusion of warranty; and each file should have at least
291 | the "copyright" line and a pointer to where the full notice is found.
292 |
293 | {description}
294 | Copyright (C) {year} {fullname}
295 |
296 | This program is free software; you can redistribute it and/or modify
297 | it under the terms of the GNU General Public License as published by
298 | the Free Software Foundation; either version 2 of the License, or
299 | (at your option) any later version.
300 |
301 | This program is distributed in the hope that it will be useful,
302 | but WITHOUT ANY WARRANTY; without even the implied warranty of
303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
304 | GNU General Public License for more details.
305 |
306 | You should have received a copy of the GNU General Public License along
307 | with this program; if not, write to the Free Software Foundation, Inc.,
308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
309 |
310 | Also add information on how to contact you by electronic and paper mail.
311 |
312 | If the program is interactive, make it output a short notice like this
313 | when it starts in an interactive mode:
314 |
315 | Gnomovision version 69, Copyright (C) year name of author
316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
317 | This is free software, and you are welcome to redistribute it
318 | under certain conditions; type `show c' for details.
319 |
320 | The hypothetical commands `show w' and `show c' should show the appropriate
321 | parts of the General Public License. Of course, the commands you use may
322 | be called something other than `show w' and `show c'; they could even be
323 | mouse-clicks or menu items--whatever suits your program.
324 |
325 | You should also get your employer (if you work as a programmer) or your
326 | school, if any, to sign a "copyright disclaimer" for the program, if
327 | necessary. Here is a sample; alter the names:
328 |
329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program
330 | `Gnomovision' (which makes passes at compilers) written by James Hacker.
331 |
332 | {signature of Ty Coon}, 1 April 1989
333 | Ty Coon, President of Vice
334 |
335 | This General Public License does not permit incorporating your program into
336 | proprietary programs. If your program is a subroutine library, you may
337 | consider it more useful to permit linking proprietary applications with the
338 | library. If this is what you want to do, use the GNU Lesser General
339 | Public License instead of this License.
340 |
--------------------------------------------------------------------------------
/src/qtiles/qtilesdialog.py:
--------------------------------------------------------------------------------
1 | # ******************************************************************************
2 | #
3 | # QTiles
4 | # ---------------------------------------------------------
5 | # Generates tiles from QGIS project
6 | #
7 | # Copyright (C) 2012-2022 NextGIS (info@nextgis.com)
8 | #
9 | # This source is free software; you can redistribute it and/or modify it under
10 | # the terms of the GNU General Public License as published by the Free
11 | # Software Foundation, either version 2 of the License, or (at your option)
12 | # any later version.
13 | #
14 | # This code is distributed in the hope that it will be useful, but WITHOUT ANY
15 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 | # details.
18 | #
19 | # A copy of the GNU General Public License is available on the World Wide Web
20 | # at . You can also obtain it by writing
21 | # to the Free Software Foundation, 51 Franklin Street, Suite 500 Boston,
22 | # MA 02110-1335 USA.
23 | #
24 | # ******************************************************************************
25 | import operator
26 | import os
27 | import shutil
28 | from pathlib import Path
29 | from typing import TYPE_CHECKING, List, Optional
30 |
31 | if TYPE_CHECKING:
32 | from qgis.gui import QgsInterface
33 |
34 | from qgis.core import QgsFileUtils, QgsMapLayer
35 | from qgis.gui import QgsGui
36 | from qgis.PyQt import uic
37 | from qgis.PyQt.QtCore import QFileInfo, Qt, pyqtSlot
38 | from qgis.PyQt.QtGui import QIcon
39 | from qgis.PyQt.QtWidgets import (
40 | QDialog,
41 | QDialogButtonBox,
42 | QFileDialog,
43 | QMessageBox,
44 | )
45 |
46 | from qtiles.restrictions import OpenStreetMapRestriction
47 | from qtiles.tile import Tile
48 |
49 | from . import qtiles_utils as utils
50 | from . import tilingthread
51 | from .compat import QgsSettings
52 |
53 | FORM_CLASS, _ = uic.loadUiType(
54 | os.path.join(os.path.dirname(__file__), "ui/qtilesdialogbase.ui")
55 | )
56 |
57 |
58 | class QTilesDialog(QDialog, FORM_CLASS):
59 | """
60 | QTilesDialog is the main dialog for configuring
61 | and generating map tiles from a QGIS project.
62 | """
63 |
64 | # MAX_ZOOM_LEVEL = 18
65 | MIN_ZOOM_LEVEL = 0
66 |
67 | def __init__(self, iface: "QgsInterface") -> None:
68 | """
69 | Initializes the QTilesDialog with the given QGIS interface.
70 |
71 | :param iface: The QGIS interface object for interacting with the QGIS application.
72 | """
73 | super().__init__()
74 | self.setupUi(self)
75 |
76 | self.setObjectName("qtiles_main_window")
77 | QgsGui.enableAutoGeometryRestore(self, "qtiles_main_window")
78 |
79 | self.btnOk = self.buttonBox.addButton(
80 | self.tr("Run"), QDialogButtonBox.ButtonRole.AcceptRole
81 | )
82 |
83 | # self.spnZoomMax.setMaximum(self.MAX_ZOOM_LEVEL)
84 | self.spnZoomMax.setMinimum(self.MIN_ZOOM_LEVEL)
85 | # self.spnZoomMin.setMaximum(self.MAX_ZOOM_LEVEL)
86 | self.spnZoomMin.setMinimum(self.MIN_ZOOM_LEVEL)
87 |
88 | self.spnZoomMin.valueChanged.connect(self.spnZoomMax.setMinimum)
89 | self.spnZoomMax.valueChanged.connect(self.spnZoomMin.setMaximum)
90 |
91 | self.iface = iface
92 |
93 | self.verticalLayout_2.setAlignment(Qt.AlignmentFlag.AlignTop)
94 |
95 | self.workThread = None
96 |
97 | self.FORMATS = {
98 | self.tr("ZIP archives (*.zip *.ZIP)"): ".zip",
99 | self.tr("MBTiles databases (*.mbtiles *.MBTILES)"): ".mbtiles",
100 | }
101 |
102 | self.settings = QgsSettings("NextGIS", "QTiles")
103 | self.grpParameters.setSettings(self.settings)
104 | self.btnClose = self.buttonBox.button(
105 | QDialogButtonBox.StandardButton.Close
106 | )
107 | self.rbExtentLayer.toggled.connect(self.__toggleLayerSelector)
108 | self.chkLockRatio.stateChanged.connect(self.__toggleHeightEdit)
109 | self.spnTileWidth.valueChanged.connect(self.__updateTileSize)
110 | self.btnBrowse.clicked.connect(self.__select_output)
111 | self.cmbFormat.activated.connect(self.formatChanged)
112 |
113 | self.rbOutputZip.toggled.connect(self.__toggleTarget)
114 | self.rbOutputDir.toggled.connect(self.__toggleTarget)
115 | self.rbOutputNGM.toggled.connect(self.__toggleTarget)
116 | self.rbOutputNGM.setIcon(
117 | QIcon(":/plugins/qtiles/icons/ngm_index_24x24.png")
118 | )
119 |
120 | self.lInfoIconOutputZip.linkActivated.connect(self.show_output_info)
121 | self.lInfoIconOutputDir.linkActivated.connect(self.show_output_info)
122 | self.lInfoIconOutputNGM.linkActivated.connect(self.show_output_info)
123 |
124 | self.manageGui()
125 |
126 | def show_output_info(self, href: str) -> None:
127 | """
128 | Displays information about the selected output type.
129 |
130 | :param href: The hyperlink reference associated with the clicked icon.
131 | """
132 | title = self.tr("Output type info")
133 | message = ""
134 | if self.sender() is self.lInfoIconOutputZip:
135 | message = self.tr("Save tiles as Zip or MBTiles")
136 | elif self.sender() is self.lInfoIconOutputDir:
137 | message = self.tr("Save tiles as directory structure")
138 | elif self.sender() is self.lInfoIconOutputNGM:
139 | message = (
140 | "
\
141 |
\
142 | \
143 |
\
144 |
\
145 | %s \
146 |
\
147 |
"
148 | % self.tr(
149 | "Prepare package for NextGIS Mobile "
150 | )
151 | )
152 |
153 | # QMessageBox.information(
154 | # self,
155 | # title,
156 | # message
157 | # )
158 | msgBox = QMessageBox()
159 | msgBox.setWindowTitle(title)
160 | msgBox.setText(message)
161 | msgBox.exec()
162 |
163 | def formatChanged(self) -> None:
164 | """
165 | Updates the GUI based on the selected output format.
166 |
167 | This method enables or disables certain input fields depending on
168 | whether the selected format is JPG or another format.
169 | """
170 | if self.cmbFormat.currentText() == "JPG":
171 | self.spnTransparency.setEnabled(False)
172 | self.spnQuality.setEnabled(True)
173 | else:
174 | self.spnTransparency.setEnabled(True)
175 | self.spnQuality.setEnabled(False)
176 |
177 | def manageGui(self) -> None:
178 | """
179 | Configures the GUI elements based on saved settings and user input.
180 | """
181 | layers = utils.getMapLayers()
182 | for layer in sorted(
183 | iter(list(layers.items())), key=operator.itemgetter(1)
184 | ):
185 | groupName = utils.getLayerGroup(layer[0])
186 | if groupName == "":
187 | self.cmbLayers.addItem(layer[1], layer[0])
188 | else:
189 | self.cmbLayers.addItem(
190 | "%s - %s" % (layer[1], groupName), layer[0]
191 | )
192 |
193 | self.rbOutputZip.setChecked(
194 | self.settings.value("outputToZip", True, type=bool)
195 | )
196 | self.rbOutputDir.setChecked(
197 | self.settings.value("outputToDir", False, type=bool)
198 | )
199 | self.rbOutputNGM.setChecked(
200 | self.settings.value("outputToNGM", False, type=bool)
201 | )
202 | if self.rbOutputZip.isChecked():
203 | self.leDirectoryName.setEnabled(False)
204 | self.leTilesFroNGM.setEnabled(False)
205 | elif self.rbOutputDir.isChecked():
206 | self.leZipFileName.setEnabled(False)
207 | self.leTilesFroNGM.setEnabled(False)
208 | elif self.rbOutputNGM.isChecked():
209 | self.leZipFileName.setEnabled(False)
210 | self.leDirectoryName.setEnabled(False)
211 | else:
212 | self.leZipFileName.setEnabled(False)
213 | self.leDirectoryName.setEnabled(False)
214 | self.leTilesFroNGM.setEnabled(False)
215 |
216 | self.leZipFileName.setText(self.settings.value("outputToZip_Path", ""))
217 | self.leDirectoryName.setText(
218 | self.settings.value("outputToDir_Path", "")
219 | )
220 | self.leTilesFroNGM.setText(self.settings.value("outputToNGM_Path", ""))
221 |
222 | self.cmbLayers.setEnabled(False)
223 | self.leRootDir.setText(self.settings.value("rootDir", "Mapnik"))
224 | self.rbExtentCanvas.setChecked(
225 | self.settings.value("extentCanvas", True, type=bool)
226 | )
227 | self.rbExtentFull.setChecked(
228 | self.settings.value("extentFull", False, type=bool)
229 | )
230 | self.rbExtentLayer.setChecked(
231 | self.settings.value("extentLayer", False, type=bool)
232 | )
233 | self.spnZoomMin.setValue(self.settings.value("minZoom", 0, type=int))
234 | self.spnZoomMax.setValue(self.settings.value("maxZoom", 18, type=int))
235 | self.chkLockRatio.setChecked(
236 | self.settings.value("keepRatio", True, type=bool)
237 | )
238 | self.spnTileWidth.setValue(
239 | self.settings.value("tileWidth", 256, type=int)
240 | )
241 | self.spnTileHeight.setValue(
242 | self.settings.value("tileHeight", 256, type=int)
243 | )
244 | self.spnTransparency.setValue(
245 | self.settings.value("transparency", 255, type=int)
246 | )
247 | self.spnQuality.setValue(self.settings.value("quality", 70, type=int))
248 | self.cmbFormat.setCurrentIndex(int(self.settings.value("format", 0)))
249 | self.chkAntialiasing.setChecked(
250 | self.settings.value("enable_antialiasing", False, type=bool)
251 | )
252 | self.chkTMSConvention.setChecked(
253 | self.settings.value("use_tms_filenames", False, type=bool)
254 | )
255 | self.chkMBTilesCompression.setChecked(
256 | self.settings.value("use_mbtiles_compression", False, type=bool)
257 | )
258 | self.chkWriteJson.setChecked(
259 | self.settings.value("write_json", False, type=bool)
260 | )
261 | self.chkWriteOverview.setChecked(
262 | self.settings.value("write_overview", False, type=bool)
263 | )
264 | self.chkWriteMapurl.setChecked(
265 | self.settings.value("write_mapurl", False, type=bool)
266 | )
267 | self.chkWriteViewer.setChecked(
268 | self.settings.value("write_viewer", False, type=bool)
269 | )
270 | self.chkRenderOutsideTiles.setChecked(
271 | self.settings.value("renderOutsideTiles", True, type=bool)
272 | )
273 |
274 | self.formatChanged()
275 |
276 | def reject(self) -> None:
277 | """
278 | Closes the dialog without saving changes.
279 | """
280 | super().reject()
281 |
282 | def accept(self) -> None:
283 | """
284 | Validates user input and starts the tile generation process.
285 | """
286 | if self.rbOutputZip.isChecked():
287 | output_path_str = self.leZipFileName.text()
288 | elif self.rbOutputDir.isChecked():
289 | output_path_str = self.leDirectoryName.text()
290 | elif self.rbOutputNGM.isChecked():
291 | output_path_str = self.leTilesFroNGM.text()
292 | else:
293 | output_path_str = ""
294 |
295 | if not output_path_str:
296 | QMessageBox.warning(
297 | self,
298 | self.tr("Output not set"),
299 | self.tr("Output path is not set. Please specify a path."),
300 | )
301 | return
302 |
303 | output_path = Path(output_path_str)
304 |
305 | tileset_name = self.leRootDir.text()
306 | if not self.__is_tileset_name_valid(tileset_name):
307 | return
308 |
309 | if not self.__is_input_parameters_valid():
310 | return
311 |
312 | canvas = self.iface.mapCanvas()
313 | if self.rbExtentCanvas.isChecked():
314 | extent = canvas.extent()
315 | elif self.rbExtentFull.isChecked():
316 | extent = canvas.fullExtent()
317 | else:
318 | layer = utils.getLayerById(
319 | self.cmbLayers.itemData(self.cmbLayers.currentIndex())
320 | )
321 | extent = canvas.mapSettings().layerExtentToOutputExtent(
322 | layer, layer.extent()
323 | )
324 |
325 | target_extent = utils.compute_target_extent(canvas, extent)
326 |
327 | layers = canvas.layers()
328 |
329 | tms_convention = self.chkTMSConvention.isChecked()
330 | if output_path.suffix.lower() == ".mbtiles":
331 | tms_convention = True
332 |
333 | use_tms = -1 if tms_convention else 1
334 |
335 | initial_tile = Tile(0, 0, 0, use_tms)
336 | min_zoom = self.spnZoomMin.value()
337 | max_zoom = self.spnZoomMax.value()
338 | render_outside_tiles = self.chkRenderOutsideTiles.isChecked()
339 |
340 | tiles = utils.count_tiles(
341 | initial_tile,
342 | layers,
343 | target_extent,
344 | min_zoom,
345 | max_zoom,
346 | render_outside_tiles,
347 | )
348 |
349 | if tiles is None:
350 | QMessageBox.warning(
351 | self,
352 | self.tr("Error"),
353 | self.tr(
354 | "The current map extent does not intersect with the tiles. "
355 | "Please check the extent and zoom level. "
356 | "This could be caused by an invalid or out-of-bounds extent."
357 | ),
358 | QMessageBox.StandardButton.Ok,
359 | )
360 | return
361 |
362 | tiles_count = len(tiles)
363 |
364 | if tiles_count > utils.TILES_COUNT_TRESHOLD:
365 | if not self.__confirm_continue_threshold(
366 | utils.TILES_COUNT_TRESHOLD
367 | ):
368 | return
369 |
370 | layers = self.__validate_osm_restriction(layers, tiles_count)
371 | if layers is None:
372 | return
373 |
374 | if self.rbOutputZip.isChecked() or self.rbOutputNGM.isChecked():
375 | if not self.__confirm_and_overwrite_output_path(
376 | output_path, self.tr("tileset output file"), is_directory=False
377 | ):
378 | return
379 |
380 | write_mapurl = (
381 | self.chkWriteMapurl.isEnabled() and self.chkWriteMapurl.isChecked()
382 | )
383 | write_viewer = (
384 | self.chkWriteViewer.isEnabled() and self.chkWriteViewer.isChecked()
385 | )
386 |
387 | if write_viewer:
388 | viewer_dir = output_path / f"{tileset_name}_viewer"
389 | if not self.__confirm_and_overwrite_output_path(
390 | viewer_dir, self.tr("viewer directory"), is_directory=True
391 | ):
392 | return
393 |
394 | if self.rbOutputDir.isChecked():
395 | tileset_dir = output_path / tileset_name
396 | if not self.__confirm_and_overwrite_output_path(
397 | tileset_dir,
398 | self.tr("tileset output directory"),
399 | is_directory=True,
400 | ):
401 | return
402 |
403 | self.__save_settings()
404 |
405 | self.workThread = tilingthread.TilingThread(
406 | tiles,
407 | layers,
408 | target_extent,
409 | min_zoom,
410 | max_zoom,
411 | self.spnTileWidth.value(),
412 | self.spnTileHeight.value(),
413 | self.spnTransparency.value(),
414 | self.spnQuality.value(),
415 | self.cmbFormat.currentText(),
416 | output_path,
417 | self.leRootDir.text(),
418 | self.chkAntialiasing.isChecked(),
419 | tms_convention,
420 | self.chkMBTilesCompression.isChecked(),
421 | self.chkWriteJson.isChecked(),
422 | self.chkWriteOverview.isChecked(),
423 | write_mapurl,
424 | write_viewer,
425 | )
426 |
427 | self.workThread.rangeChanged.connect(self.setProgressRange)
428 | self.workThread.updateProgress.connect(self.updateProgress)
429 | self.workThread.processFinished.connect(self.processFinished)
430 | self.workThread.processInterrupted.connect(self.processInterrupted)
431 | self.btnOk.setEnabled(False)
432 | self.btnClose.setText(self.tr("Cancel"))
433 | self.buttonBox.rejected.disconnect(self.reject)
434 | self.btnClose.clicked.connect(self.stopProcessing)
435 | self.workThread.start()
436 |
437 | def __confirm_continue_threshold(self, tiles_count_threshold: int) -> bool:
438 | """
439 | Confirms whether to proceed with tile generation
440 | when the estimated tile count exceeds a given threshold.
441 |
442 | :param tiles_count_threshold: The estimated threshold of tile count
443 | that triggers the confirmation.
444 | """
445 | reply = QMessageBox.question(
446 | self,
447 | self.tr("Confirmation"),
448 | self.tr("Estimate number of tiles more then {}! Continue?").format(
449 | tiles_count_threshold
450 | ),
451 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
452 | )
453 |
454 | return reply == QMessageBox.StandardButton.Yes
455 |
456 | @pyqtSlot(str, int)
457 | def setProgressRange(self, message: str, value: int) -> None:
458 | """
459 | Sets the progress bar range and updates its message.
460 |
461 | :param message: A string containing the message to be displayed
462 | on the progress bar.
463 | :param value: The total value indicating the range of progress.
464 | """
465 | self.progressBar.setFormat(message)
466 | self.progressBar.setRange(0, value)
467 |
468 | @pyqtSlot()
469 | def updateProgress(self) -> None:
470 | """
471 | Updates the progress bar by incrementing its current value.
472 | """
473 | self.progressBar.setValue(self.progressBar.value() + 1)
474 |
475 | @pyqtSlot()
476 | def processInterrupted(self) -> None:
477 | """
478 | Restores the GUI state after the tile generation process is interrupted.
479 | """
480 | self.restoreGui()
481 |
482 | @pyqtSlot()
483 | def processFinished(self) -> None:
484 | """
485 | Restores the GUI state and stops
486 | the tile generation process when it is completed.
487 | """
488 | self.stopProcessing()
489 | self.restoreGui()
490 |
491 | def stopProcessing(self) -> None:
492 | """
493 | Stops the tile generation process if it is running.
494 | """
495 | if self.workThread is not None:
496 | self.workThread.stop()
497 | self.workThread = None
498 |
499 | def restoreGui(self) -> None:
500 | """
501 | Restores the initial GUI state
502 | after a process has finished or been interrupted.
503 | """
504 | self.progressBar.setFormat("%p%")
505 | self.progressBar.setRange(0, 1)
506 | self.progressBar.setValue(0)
507 | self.buttonBox.rejected.connect(self.reject)
508 | self.btnClose.clicked.disconnect(self.stopProcessing)
509 | self.btnClose.setText(self.tr("Close"))
510 | self.btnOk.setEnabled(True)
511 |
512 | def __toggleTarget(self, checked: bool) -> None:
513 | """
514 | Toggles the availability of target-related input fields.
515 |
516 | This method is triggered when the user selects a different output
517 | target (e.g., ZIP, directory, or NGM package).
518 |
519 | :param checked: A boolean indicating whether the target is selected.
520 | """
521 | if checked:
522 | if self.sender() is self.rbOutputZip:
523 | self.leZipFileName.setEnabled(True)
524 | self.leDirectoryName.setEnabled(False)
525 | self.leTilesFroNGM.setEnabled(False)
526 | self.chkWriteMapurl.setEnabled(False)
527 | self.chkWriteViewer.setEnabled(False)
528 | self.chkWriteJson.setEnabled(True)
529 |
530 | self.spnTileWidth.setEnabled(True)
531 | self.chkLockRatio.setEnabled(True)
532 | self.cmbFormat.setEnabled(True)
533 | self.chkMBTilesCompression.setEnabled(True)
534 |
535 | self.chkWriteOverview.setEnabled(True)
536 | elif self.sender() is self.rbOutputDir:
537 | self.leZipFileName.setEnabled(False)
538 | self.leDirectoryName.setEnabled(True)
539 | self.leTilesFroNGM.setEnabled(False)
540 | self.chkWriteMapurl.setEnabled(True)
541 | self.chkWriteViewer.setEnabled(True)
542 | self.chkWriteJson.setEnabled(True)
543 | self.chkMBTilesCompression.setEnabled(False)
544 |
545 | self.spnTileWidth.setEnabled(True)
546 | self.chkLockRatio.setEnabled(True)
547 | self.cmbFormat.setEnabled(True)
548 |
549 | self.chkWriteOverview.setEnabled(True)
550 | elif self.sender() is self.rbOutputNGM:
551 | self.leZipFileName.setEnabled(False)
552 | self.leDirectoryName.setEnabled(False)
553 | self.leTilesFroNGM.setEnabled(True)
554 | self.chkWriteMapurl.setEnabled(False)
555 | self.chkWriteViewer.setEnabled(False)
556 | self.chkMBTilesCompression.setEnabled(False)
557 |
558 | self.spnTileWidth.setValue(256)
559 | self.spnTileWidth.setEnabled(False)
560 | self.chkLockRatio.setCheckState(Qt.CheckState.Checked)
561 | self.chkLockRatio.setEnabled(False)
562 | self.cmbFormat.setCurrentIndex(0)
563 | self.cmbFormat.setEnabled(True)
564 |
565 | self.chkWriteOverview.setChecked(False)
566 | self.chkWriteOverview.setEnabled(False)
567 |
568 | self.chkWriteJson.setChecked(False)
569 | self.chkWriteJson.setEnabled(False)
570 |
571 | def __toggleLayerSelector(self, checked: bool) -> None:
572 | """
573 | Toggles the visibility of the layer selector based on user input.
574 |
575 | :param checked: A boolean indicating whether the layer selector should be visible.
576 | """
577 | self.cmbLayers.setEnabled(checked)
578 |
579 | def __toggleHeightEdit(self, state: int) -> None:
580 | """
581 | Enables or disables the height input field based on the lock ratio
582 | checkbox.
583 |
584 | :param state: The state of the lock ratio checkbox (checked or unchecked).
585 | """
586 | if state == Qt.CheckState.Checked:
587 | self.lblHeight.setEnabled(False)
588 | self.spnTileHeight.setEnabled(False)
589 | self.spnTileHeight.setValue(self.spnTileWidth.value())
590 | else:
591 | self.lblHeight.setEnabled(True)
592 | self.spnTileHeight.setEnabled(True)
593 |
594 | @pyqtSlot(int)
595 | def __updateTileSize(self, value: int) -> None:
596 | """
597 | Updates the tile size based on user input.
598 |
599 | This method ensures that the tile width and height remain consistent
600 | when the user changes one of the dimensions.
601 |
602 | :param value: The new value for the tile size.
603 | """
604 | if self.chkLockRatio.isChecked():
605 | self.spnTileHeight.setValue(value)
606 |
607 | def __select_output(self) -> None:
608 | """
609 | Opens a file dialog for selecting the output path.
610 | """
611 | if self.rbOutputZip.isChecked():
612 | file_directory = QFileInfo(
613 | self.settings.value("outputToZip_Path", ".")
614 | ).absolutePath()
615 | output_path, output_filter = QFileDialog.getSaveFileName(
616 | self,
617 | self.tr("Save to file"),
618 | file_directory,
619 | ";;".join(iter(list(self.FORMATS.keys()))),
620 | )
621 | if not output_path:
622 | return
623 |
624 | ext = ".zip" if "zip" in output_filter.lower() else ".mbtiles"
625 |
626 | output_path = QgsFileUtils.ensureFileNameHasExtension(
627 | output_path, [ext]
628 | )
629 |
630 | self.leZipFileName.setText(output_path)
631 | self.settings.setValue(
632 | "outputToZip_Path", QFileInfo(output_path).absoluteFilePath()
633 | )
634 |
635 | elif self.rbOutputDir.isChecked():
636 | dir_directory = QFileInfo(
637 | self.settings.value("outputToDir_Path", ".")
638 | ).absolutePath()
639 | output_path = QFileDialog.getExistingDirectory(
640 | self,
641 | self.tr("Save to directory"),
642 | dir_directory,
643 | QFileDialog.Option.ShowDirsOnly,
644 | )
645 | if not output_path:
646 | return
647 | self.leDirectoryName.setText(output_path)
648 | self.settings.setValue(
649 | "outputToDir_Path", QFileInfo(output_path).absoluteFilePath()
650 | )
651 |
652 | elif self.rbOutputNGM.isChecked():
653 | zip_directory = QFileInfo(
654 | self.settings.value("outputToNGM_Path", ".")
655 | ).absolutePath()
656 | output_path, output_filter = QFileDialog.getSaveFileName(
657 | self, self.tr("Save to file"), zip_directory, "NGRC (*.ngrc)"
658 | )
659 | if not output_path:
660 | return
661 |
662 | output_path = QgsFileUtils.ensureFileNameHasExtension(
663 | output_path, [".ngrc"]
664 | )
665 |
666 | self.leTilesFroNGM.setText(output_path)
667 | self.settings.setValue(
668 | "outputToNGM_Path", QFileInfo(output_path).absoluteFilePath()
669 | )
670 |
671 | def __is_input_parameters_valid(self) -> bool:
672 | if (
673 | self.rbExtentLayer.isChecked()
674 | and self.cmbLayers.currentIndex() < 0
675 | ):
676 | QMessageBox.warning(
677 | self,
678 | self.tr("Layer not selected"),
679 | self.tr("Please select a layer and try again."),
680 | )
681 | return False
682 |
683 | min_zoom = self.spnZoomMin.value()
684 | max_zoom = self.spnZoomMax.value()
685 | if min_zoom > max_zoom:
686 | QMessageBox.warning(
687 | self,
688 | self.tr("Wrong zoom"),
689 | self.tr(
690 | "Maximum zoom value is lower than minimum. Please correct this and try again."
691 | ),
692 | )
693 | return False
694 |
695 | return True
696 |
697 | def __save_settings(self) -> None:
698 | self.settings.setValue("rootDir", self.leRootDir.text())
699 | self.settings.setValue("outputToZip", self.rbOutputZip.isChecked())
700 | self.settings.setValue("outputToDir", self.rbOutputDir.isChecked())
701 | self.settings.setValue("outputToNGM", self.rbOutputNGM.isChecked())
702 | self.settings.setValue("extentCanvas", self.rbExtentCanvas.isChecked())
703 | self.settings.setValue("extentFull", self.rbExtentFull.isChecked())
704 | self.settings.setValue("extentLayer", self.rbExtentLayer.isChecked())
705 | self.settings.setValue("minZoom", self.spnZoomMin.value())
706 | self.settings.setValue("maxZoom", self.spnZoomMax.value())
707 | self.settings.setValue("keepRatio", self.chkLockRatio.isChecked())
708 | self.settings.setValue("tileWidth", self.spnTileWidth.value())
709 | self.settings.setValue("tileHeight", self.spnTileHeight.value())
710 | self.settings.setValue("format", self.cmbFormat.currentIndex())
711 | self.settings.setValue("transparency", self.spnTransparency.value())
712 | self.settings.setValue("quality", self.spnQuality.value())
713 | self.settings.setValue(
714 | "enable_antialiasing", self.chkAntialiasing.isChecked()
715 | )
716 | self.settings.setValue(
717 | "use_tms_filenames", self.chkTMSConvention.isChecked()
718 | )
719 | self.settings.setValue(
720 | "use_mbtiles_compression", self.chkMBTilesCompression.isChecked()
721 | )
722 | self.settings.setValue("write_json", self.chkWriteJson.isChecked())
723 | self.settings.setValue(
724 | "write_overview", self.chkWriteOverview.isChecked()
725 | )
726 | self.settings.setValue("write_mapurl", self.chkWriteMapurl.isChecked())
727 | self.settings.setValue("write_viewer", self.chkWriteViewer.isChecked())
728 | self.settings.setValue(
729 | "renderOutsideTiles", self.chkRenderOutsideTiles.isChecked()
730 | )
731 |
732 | def __validate_osm_restriction(
733 | self, layers: List[QgsMapLayer], tiles_count: int
734 | ) -> Optional[List[QgsMapLayer]]:
735 | """
736 | Validates OSM tile usage restrictions and optionally filters out
737 | prohibited layers after user confirmation.
738 |
739 | :param layers: List of canvas layers.
740 | :param tiles_count: Final number of tiles to be generated.
741 | :returns:
742 | - Updated list of layers if generation can continue.
743 | - None if the user cancels or all OSM layers must be skipped.
744 | """
745 | osm_restriction = OpenStreetMapRestriction()
746 | is_violated, message, skipped_layers = (
747 | osm_restriction.validate_restriction(layers, tiles_count)
748 | )
749 |
750 | if not is_violated:
751 | return layers
752 |
753 | if len(skipped_layers) == len(layers):
754 | QMessageBox.warning(
755 | self,
756 | self.tr("OpenStreetMap Layer Restriction"),
757 | message,
758 | QMessageBox.StandardButton.Ok,
759 | )
760 | return None
761 |
762 | reply = QMessageBox.question(
763 | self,
764 | self.tr("OpenStreetMap Layer Restriction"),
765 | message
766 | + "
"
767 | + self.tr(
768 | "Are you sure you want to continue without OpenStreetMap layers?"
769 | ),
770 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
771 | QMessageBox.StandardButton.No,
772 | )
773 |
774 | if reply == QMessageBox.StandardButton.No:
775 | return None
776 |
777 | return [layer for layer in layers if layer not in skipped_layers]
778 |
779 | def __confirm_and_overwrite_output_path(
780 | self, output_path: Path, description: str, is_directory: bool = False
781 | ) -> bool:
782 | """
783 | Checks if a file or directory exists, prompts the user for confirmation
784 | to overwrite, and attempts to remove it.
785 |
786 | :param output_path: The path to the file or directory.
787 | :param description: Human-readable description of the object
788 | (e.g., 'tileset directory', 'viewer directory', 'output file').
789 | :param is_directory: True if the path is a directory, False if a file.
790 |
791 | :return: True if path was successfully removed or did not exist;
792 | False if user cancelled or an error occurred.
793 | """
794 | if not output_path.exists():
795 | return True
796 |
797 | message = self.tr(
798 | "The {desc} already exists and will be overwritten:\n"
799 | "{path}\n\n"
800 | "Are you sure you want to continue?"
801 | ).format(desc=description, path=str(output_path))
802 |
803 | reply = QMessageBox.question(
804 | self,
805 | self.tr("Output path exists"),
806 | message,
807 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
808 | QMessageBox.StandardButton.No,
809 | )
810 |
811 | if reply != QMessageBox.StandardButton.Yes:
812 | return False
813 |
814 | try:
815 | if is_directory:
816 | shutil.rmtree(output_path)
817 | else:
818 | output_path.unlink()
819 | return True
820 | except Exception as error:
821 | QMessageBox.critical(
822 | self,
823 | self.tr("Cannot overwrite"),
824 | self.tr(
825 | "Failed to overwrite {desc}:\n{path}\n\nError: {err}"
826 | ).format(
827 | desc=description, path=str(output_path), err=str(error)
828 | ),
829 | )
830 | return False
831 |
832 | def __is_tileset_name_valid(self, tileset_name: str) -> bool:
833 | """
834 | Validates the tileset name to ensure it is safe for use as a folder name.
835 |
836 | :param tileset_name: The name of the tileset folder provided by the user.
837 | :return: True if the tileset name is valid, False otherwise.
838 | """
839 | forbidden_names = {".", ".."}
840 | forbidden_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
841 |
842 | if not tileset_name.strip():
843 | QMessageBox.warning(
844 | self,
845 | self.tr("Invalid tileset name"),
846 | self.tr(
847 | "Tileset name cannot be empty. Please specify a name."
848 | ),
849 | )
850 | return False
851 |
852 | if tileset_name in forbidden_names or any(
853 | char in tileset_name for char in forbidden_chars
854 | ):
855 | QMessageBox.warning(
856 | self,
857 | self.tr("Invalid tileset name"),
858 | self.tr(
859 | "Tileset name contains forbidden characters or reserved names. "
860 | "Please choose a different name."
861 | ),
862 | )
863 | return False
864 |
865 | return True
866 |
--------------------------------------------------------------------------------