├── src └── qtiles │ ├── ui │ ├── __init__.py │ └── aboutdialogbase.ui │ ├── icons │ ├── about.png │ ├── info.png │ ├── qtiles.png │ ├── ngm_index_24x24.png │ └── nextgis_logo.svg │ ├── resources │ ├── css │ │ ├── images │ │ │ └── layers.png │ │ └── leaflet.css │ ├── js │ │ └── images │ │ │ └── ui-bg_flat_75_ffffff_40x100.png │ └── viewer.html │ ├── resources.qrc │ ├── __init__.py │ ├── tile.py │ ├── metadata.txt │ ├── compat.py │ ├── restrictions.py │ ├── qtiles.py │ ├── qtiles_utils.py │ ├── aboutdialog.py │ ├── writers.py │ ├── tilingthread.py │ ├── i18n │ └── qtiles_ru_RU.ts │ ├── mbutils.py │ └── qtilesdialog.py ├── assets └── example1.png ├── .pre-commit-config.yaml ├── README.md ├── pyproject.toml ├── .gitignore └── LICENSE /src/qtiles/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/assets/example1.png -------------------------------------------------------------------------------- /src/qtiles/icons/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/icons/about.png -------------------------------------------------------------------------------- /src/qtiles/icons/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/icons/info.png -------------------------------------------------------------------------------- /src/qtiles/icons/qtiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/icons/qtiles.png -------------------------------------------------------------------------------- /src/qtiles/icons/ngm_index_24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/icons/ngm_index_24x24.png -------------------------------------------------------------------------------- /src/qtiles/resources/css/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/resources/css/images/layers.png -------------------------------------------------------------------------------- /src/qtiles/resources/js/images/ui-bg_flat_75_ffffff_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/qgis_qtiles/HEAD/src/qtiles/resources/js/images/ui-bg_flat_75_ffffff_40x100.png -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-toml 6 | 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.11.6 9 | hooks: 10 | - id: ruff 11 | args: [--fix] 12 | - id: ruff-format 13 | -------------------------------------------------------------------------------- /src/qtiles/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/nextgis_logo.svg 4 | icons/qtiles.png 5 | icons/about.png 6 | icons/ngm_index_24x24.png 7 | icons/info.png 8 | resources/viewer.html 9 | resources/css/jquery-ui.min.css 10 | resources/css/leaflet.css 11 | resources/css/images/layers.png 12 | resources/js/jquery-ui.min.js 13 | resources/js/jquery.min.js 14 | resources/js/leaflet.js 15 | resources/js/images/ui-bg_flat_75_ffffff_40x100.png 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QTiles 2 | 3 | A QGIS plugin. Generate raster tiles from QGIS project for selected zoom levels and tile naming convention (Slippy Map or TMS). Packages tiles for a variety of formats and applications: NextGIS Mobile, SMASH, simple Leaflet-based viewer and MBTiles. 4 | 5 | QGIS plugins page: https://plugins.qgis.org/plugins/qtiles/ 6 | 7 | ## Create raster tiles from a QGIS project 8 | 9 | ![qtiles](https://github.com/nextgis/qgis_qtiles/assets/101568545/2fe48644-fe8a-405f-a528-f126b7b46e70) 10 | 11 | ## YouTube 12 | 13 | [![vU4bGCh5khM](https://github.com/nextgis/qgis_qtiles/assets/101568545/44b0cf70-740e-42a9-93e2-77544f506884)](https://youtu.be/vU4bGCh5khM) 14 | 15 | ## License 16 | 17 | This program is licensed under GNU GPL v.2 or any later version. 18 | 19 | ## Commercial support 20 | 21 | Need to fix a bug or add a feature to QTiles? 22 | 23 | We provide custom development and support for this software. [Contact us](https://nextgis.com/contact/?utm_source=nextgis-github&utm_medium=plugins&utm_campaign=qtiles) to discuss options! 24 | 25 | 26 | [![https://nextgis.com](https://nextgis.com/img/nextgis_x-logo.png)](https://nextgis.com?utm_source=nextgis-github&utm_medium=plugins&utm_campaign=qtiles) 27 | -------------------------------------------------------------------------------- /src/qtiles/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # ****************************************************************************** 4 | # 5 | # QTiles 6 | # --------------------------------------------------------- 7 | # Generates tiles from QGIS project 8 | # 9 | # Copyright (C) 2012-2014 NextGIS (info@nextgis.org) 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 | 29 | def classFactory(iface): 30 | from .qtiles import QTilesPlugin 31 | 32 | return QTilesPlugin(iface) 33 | -------------------------------------------------------------------------------- /src/qtiles/tile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # ****************************************************************************** 4 | # 5 | # QTiles 6 | # --------------------------------------------------------- 7 | # Generates tiles from QGIS project 8 | # 9 | # Copyright (C) 2012-2014 NextGIS (info@nextgis.org) 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 | 29 | import math 30 | 31 | from qgis.core import QgsRectangle 32 | from .compat import QgsPointXY 33 | 34 | 35 | class Tile: 36 | def __init__(self, x=0, y=0, z=0, tms=1): 37 | self.x = x 38 | self.y = y 39 | self.z = z 40 | self.tms = tms 41 | 42 | def toPoint(self): 43 | n = math.pow(2, self.z) 44 | longitude = float(self.x) / n * 360.0 - 180.0 45 | latitude = self.tms * math.degrees( 46 | math.atan(math.sinh(math.pi * (1.0 - 2.0 * float(self.y) / n))) 47 | ) 48 | return QgsPointXY(longitude, latitude) 49 | 50 | def toRectangle(self): 51 | return QgsRectangle( 52 | self.toPoint(), 53 | Tile(self.x + 1, self.y + 1, self.z, self.tms).toPoint(), 54 | ) 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "qtiles" 3 | version = "1.8.0" 4 | readme = "README.md" 5 | license = { file = "LICENSE" } 6 | 7 | 8 | [tool.qgspb.package-data] 9 | "qtiles.icons" = ["qtiles.png"] 10 | 11 | [tool.qgspb.forms] 12 | ui-files = ["src/qtiles/ui/*.ui"] 13 | compile = false 14 | 15 | [tool.qgspb.resources] 16 | qrc-files = ["src/qtiles/resources.qrc"] 17 | target-suffix = "_rc" 18 | 19 | [tool.qgspb.translations] 20 | ts-files = ["src/qtiles/i18n/*.ts"] 21 | no-obsolete = true 22 | 23 | 24 | [project.optional-dependencies] 25 | dev = ["ruff", "pre-commit"] 26 | 27 | [tool.pyright] 28 | include = ["src"] 29 | pythonVersion = "3.7" 30 | 31 | reportOptionalCall = false 32 | reportOptionalMemberAccess = false 33 | 34 | [tool.ruff] 35 | line-length = 79 36 | target-version = "py37" 37 | 38 | [tool.ruff.lint] 39 | select = [ 40 | # "A", # flake8-builtins 41 | # "ARG", # flake8-unused-arguments 42 | # "B", # flake8-bugbear 43 | # "C90", # mccabe complexity 44 | # "COM", # flake8-commas 45 | # "E", # pycodestyle errors 46 | # "F", # pyflakes 47 | # "FBT", # flake8-boolean-trap 48 | # "FLY", # flynt 49 | "I", # isort 50 | # "ISC", # flake8-implicit-str-concat 51 | # "LOG", # flake8-logging 52 | # "N", # pep8-naming 53 | # "PERF", # Perflint 54 | # "PGH", # pygrep-hooks 55 | # "PIE", # flake8-pie 56 | # "PL", # pylint 57 | # "PTH", # flake8-use-pathlib 58 | # "PYI", # flake8-pyi 59 | # "Q", # flake8-quotes 60 | # "RET", # flake8-return 61 | # "RSE", # flake8-raise 62 | # "RUF", 63 | # "SIM", # flake8-simplify 64 | # "SLF", # flake8-self 65 | # "T10", # flake8-debugger 66 | # "T20", # flake8-print 67 | # "TCH", # flake8-type-checking 68 | # "TD", # flake8-todos 69 | # "TID", # flake8-tidy-imports 70 | # "TRY", # tryceratops 71 | # "UP", # pyupgrade 72 | # "W", # pycodesytle warnings 73 | # "ANN", # flake8-annotations 74 | # "CPY", # flake8-copyright 75 | # "D", # pydocstyle 76 | # "FIX", # flake8-fixme 77 | ] 78 | ignore = ["ANN101", "ANN102", "TD003", "FBT003", "ISC001", "COM812", "E501"] 79 | exclude = ["resources_rc.py"] 80 | 81 | [tool.ruff.lint.per-file-ignores] 82 | "__init__.py" = ["F401"] 83 | 84 | [tool.ruff.lint.pep8-naming] 85 | extend-ignore-names = [ 86 | "setLevel", 87 | "classFactory", 88 | "initGui", 89 | "sizeHint", 90 | "createWidget", 91 | "*Event", 92 | ] 93 | -------------------------------------------------------------------------------- /src/qtiles/resources/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @{tilesetname} - LeafLet Preview 5 | 6 | 7 | 8 | 9 | 10 | 11 | 41 | 42 | 43 | 44 | 45 | 46 | 83 | 84 | 85 |
86 |
87 |
88 |
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 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 28 | 30 | 31 | 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 | }

105 |

{layers_list_html}

106 | """ 107 | 108 | if len(osm_layers) == len(layers): 109 | message += f""" 110 |

{ 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 | }

127 | """ 128 | 129 | return True, message, osm_layers 130 | -------------------------------------------------------------------------------- /src/qtiles/qtiles.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 | 26 | 27 | from qgis.core import * 28 | from qgis.PyQt.QtCore import ( 29 | QCoreApplication, 30 | QFileInfo, 31 | QLocale, 32 | QSettings, 33 | QTranslator, 34 | ) 35 | from qgis.PyQt.QtGui import QIcon 36 | from qgis.PyQt.QtWidgets import QAction, QMenu, QMessageBox 37 | from pathlib import Path 38 | 39 | from . import aboutdialog, qtilesdialog, resources_rc # noqa: F401 40 | from .compat import QGis, qgisUserDatabaseFilePath 41 | 42 | 43 | class QTilesPlugin: 44 | def __init__(self, iface): 45 | self.iface = iface 46 | 47 | self.qgsVersion = str(QGis.QGIS_VERSION_INT) 48 | 49 | userPluginPath = ( 50 | QFileInfo(qgisUserDatabaseFilePath()).path() 51 | + "/python/plugins/qtiles" 52 | ) 53 | systemPluginPath = ( 54 | QgsApplication.prefixPath() + "/python/plugins/qtiles" 55 | ) 56 | 57 | overrideLocale = QSettings().value( 58 | "locale/overrideFlag", False, type=bool 59 | ) 60 | if not overrideLocale: 61 | localeFullName = QLocale.system().name() 62 | else: 63 | localeFullName = QSettings().value("locale/userLocale", "") 64 | 65 | if QFileInfo(userPluginPath).exists(): 66 | translationPath = ( 67 | userPluginPath + "/i18n/qtiles_" + localeFullName + ".qm" 68 | ) 69 | else: 70 | translationPath = ( 71 | systemPluginPath + "/i18n/qtiles_" + localeFullName + ".qm" 72 | ) 73 | 74 | self.localePath = translationPath 75 | if QFileInfo(self.localePath).exists(): 76 | self.translator = QTranslator() 77 | self.translator.load(self.localePath) 78 | QCoreApplication.installTranslator(self.translator) 79 | 80 | def initGui(self): 81 | if int(self.qgsVersion) < 20000: 82 | qgisVersion = ( 83 | self.qgsVersion[0] 84 | + "." 85 | + self.qgsVersion[2] 86 | + "." 87 | + self.qgsVersion[3] 88 | ) 89 | QMessageBox.warning( 90 | self.iface.mainWindow(), 91 | QCoreApplication.translate("QTiles", "Error"), 92 | QCoreApplication.translate("QTiles", "QGIS %s detected.\n") 93 | % qgisVersion 94 | + QCoreApplication.translate( 95 | "QTiles", 96 | "This version of QTiles requires at least QGIS 2.0. Plugin will not be enabled.", 97 | ), 98 | ) 99 | return None 100 | 101 | self.actionRun = QAction( 102 | QCoreApplication.translate("QTiles", "QTiles"), 103 | self.iface.mainWindow(), 104 | ) 105 | self.iface.registerMainWindowAction(self.actionRun, "Shift+T") 106 | self.actionRun.setIcon(QIcon(":/plugins/qtiles/icons/qtiles.png")) 107 | self.actionRun.setWhatsThis("Generate tiles from current project") 108 | self.actionAbout = QAction( 109 | QCoreApplication.translate("QTiles", "About QTiles..."), 110 | self.iface.mainWindow(), 111 | ) 112 | self.actionAbout.setIcon(QIcon(":/plugins/qtiles/icons/about.png")) 113 | self.actionAbout.setWhatsThis("About QTiles") 114 | 115 | self.__qtiles_menu = QMenu( 116 | QCoreApplication.translate("QTiles", "QTiles") 117 | ) 118 | self.__qtiles_menu.setIcon(QIcon(":/plugins/qtiles/icons/qtiles.png")) 119 | 120 | self.__qtiles_menu.addAction(self.actionRun) 121 | self.__qtiles_menu.addAction(self.actionAbout) 122 | 123 | self.iface.pluginMenu().addMenu(self.__qtiles_menu) 124 | 125 | self.iface.addToolBarIcon(self.actionRun) 126 | 127 | self.actionRun.triggered.connect(self.run) 128 | self.actionAbout.triggered.connect(self.about) 129 | 130 | self.__show_help_action = QAction( 131 | QIcon(":/plugins/qtiles/icons/qtiles.png"), 132 | "QTiles", 133 | ) 134 | self.__show_help_action.triggered.connect(self.about) 135 | self.__plugin_help_menu = self.iface.pluginHelpMenu() 136 | assert self.__plugin_help_menu is not None 137 | self.__plugin_help_menu.addAction(self.__show_help_action) 138 | 139 | def unload(self): 140 | self.iface.unregisterMainWindowAction(self.actionRun) 141 | 142 | self.iface.removeToolBarIcon(self.actionRun) 143 | self.iface.removePluginMenu( 144 | QCoreApplication.translate("QTiles", "QTiles"), self.actionRun 145 | ) 146 | self.iface.removePluginMenu( 147 | QCoreApplication.translate("QTiles", "QTiles"), self.actionAbout 148 | ) 149 | self.__qtiles_menu.deleteLater() 150 | 151 | if self.__plugin_help_menu: 152 | self.__plugin_help_menu.removeAction(self.__show_help_action) 153 | self.__show_help_action.deleteLater() 154 | self.__show_help_action = None 155 | 156 | def run(self): 157 | d = qtilesdialog.QTilesDialog(self.iface) 158 | d.show() 159 | d.exec() 160 | 161 | def about(self): 162 | package_name = str(Path(__file__).parent.name) 163 | d = aboutdialog.AboutDialog(package_name) 164 | d.exec() 165 | -------------------------------------------------------------------------------- /src/qtiles/qtiles_utils.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 | import math 28 | from pathlib import Path 29 | from typing import List, Optional 30 | 31 | from qgis.core import ( 32 | QgsMapLayer, 33 | QgsProject, 34 | QgsRectangle, 35 | ) 36 | from qgis.gui import QgsMapCanvas 37 | from qgis.PyQt.QtCore import QFile, QIODevice 38 | 39 | from qtiles.compat import ( 40 | QgsCoordinateReferenceSystem, 41 | QgsCoordinateTransform, 42 | mapLayers, 43 | ) 44 | from qtiles.tile import Tile 45 | 46 | TILES_COUNT_TRESHOLD = 10000 47 | 48 | 49 | def getMapLayers(): 50 | layers = dict() 51 | for name, layer in list(mapLayers().items()): 52 | if layer.type() == QgsMapLayer.VectorLayer: 53 | if layer.id() not in list(layers.keys()): 54 | layers[layer.id()] = str(layer.name()) 55 | if ( 56 | layer.type() == QgsMapLayer.RasterLayer 57 | and layer.providerType() == "gdal" 58 | ): 59 | if layer.id() not in list(layers.keys()): 60 | layers[layer.id()] = str(layer.name()) 61 | return layers 62 | 63 | 64 | def getLayerById(layerId): 65 | for name, layer in list(mapLayers().items()): 66 | if layer.id() == layerId: 67 | if layer.isValid(): 68 | return layer 69 | else: 70 | return None 71 | 72 | 73 | def getLayerGroup(layerId): 74 | return ( 75 | QgsProject.instance() 76 | .layerTreeRoot() 77 | .findLayer(layerId) 78 | .parent() 79 | .name() 80 | ) 81 | 82 | 83 | def compute_target_extent(canvas: QgsMapCanvas, extent) -> QgsRectangle: 84 | transformed_extent = QgsCoordinateTransform( 85 | canvas.mapSettings().destinationCrs(), 86 | QgsCoordinateReferenceSystem.fromEpsgId(4326), 87 | ).transform(extent) 88 | 89 | arctan_sinh_pi = math.degrees(math.atan(math.sinh(math.pi))) 90 | target_extent = transformed_extent.intersect( 91 | QgsRectangle(-180, -arctan_sinh_pi, 180, arctan_sinh_pi) 92 | ) 93 | 94 | return target_extent 95 | 96 | 97 | def count_tiles( 98 | tile: Tile, 99 | layers: List[QgsMapLayer], 100 | extent: QgsRectangle, 101 | min_zoom: int, 102 | max_zoom: int, 103 | render_outside_tiles: bool, 104 | ) -> Optional[List[Tile]]: 105 | """ 106 | Recursively counts the number of tiles to be generated. 107 | 108 | This method calculates the tiles required for the specified extent 109 | and zoom levels. It supports rendering tiles outside the map extent 110 | if enabled. 111 | 112 | :param tile: The initial tile to start counting from. 113 | :param layers: A list of map layers to consider for tile generation. 114 | :param extent: The geographical extent for tile generation. 115 | :param min_zoom: The minimum zoom level. 116 | :param max_zoom: The maximum zoom level. 117 | :param render_outside_tiles: Whether to include tiles outside themap extent. 118 | 119 | :returns: A list of tiles to be generated or None if no tiles are required. 120 | """ 121 | if not extent.intersects(tile.toRectangle()): 122 | return None 123 | 124 | tiles = [] 125 | if min_zoom <= tile.z and tile.z <= max_zoom: 126 | if not render_outside_tiles: 127 | for layer in layers: 128 | crs_transform = QgsCoordinateTransform( 129 | layer.crs(), 130 | QgsCoordinateReferenceSystem.fromEpsgId(4326), 131 | ) 132 | if crs_transform.transform(layer.extent()).intersects( 133 | tile.toRectangle() 134 | ): 135 | tiles.append(tile) 136 | break 137 | else: 138 | tiles.append(tile) 139 | if tile.z < max_zoom: 140 | for x in range(2 * tile.x, 2 * tile.x + 2, 1): 141 | for y in range(2 * tile.y, 2 * tile.y + 2, 1): 142 | sub_tile = Tile(x, y, tile.z + 1, tile.tms) 143 | sub_tiles = count_tiles( 144 | sub_tile, 145 | layers, 146 | extent, 147 | min_zoom, 148 | max_zoom, 149 | render_outside_tiles, 150 | ) 151 | if sub_tiles: 152 | tiles.extend(sub_tiles) 153 | 154 | return tiles 155 | 156 | 157 | def copy_resource(qrc_path: str, target: Path) -> None: 158 | """ 159 | Copy a file from Qt resource system (`qrc_path`) to filesystem. 160 | 161 | :param qrc_path: Path to the resource inside the Qt resource system. 162 | :param target: Destination path on the filesystem. 163 | :returns: None. 164 | """ 165 | target.parent.mkdir(parents=True, exist_ok=True) 166 | 167 | file = QFile(qrc_path) 168 | if not file.open(QIODevice.OpenModeFlag.ReadOnly): 169 | return 170 | 171 | data = file.readAll() 172 | with open(str(target), "wb") as resource_file: 173 | resource_file.write(bytes(data)) 174 | 175 | file.close() 176 | 177 | 178 | def create_viewer_directory(viewer_dir: Path): 179 | """ 180 | Creates a Leaflet viewer directory with all required resources. 181 | 182 | :param viewer_dir: Desired base path for the viewer directory. 183 | """ 184 | (viewer_dir / "css/images").mkdir(parents=True, exist_ok=True) 185 | (viewer_dir / "js/images").mkdir(parents=True, exist_ok=True) 186 | 187 | resources = [ 188 | ("css/leaflet.css", "css/leaflet.css"), 189 | ("css/jquery-ui.min.css", "css/jquery-ui.min.css"), 190 | ("css/images/layers.png", "css/images/layers.png"), 191 | ("js/leaflet.js", "js/leaflet.js"), 192 | ("js/jquery.min.js", "js/jquery.min.js"), 193 | ("js/jquery-ui.min.js", "js/jquery-ui.min.js"), 194 | ( 195 | "js/images/ui-bg_flat_75_ffffff_40x100.png", 196 | "js/images/ui-bg_flat_75_ffffff_40x100.png", 197 | ), 198 | ] 199 | 200 | for src, dest in resources: 201 | copy_resource(f":/plugins/qtiles/resources/{src}", viewer_dir / dest) 202 | -------------------------------------------------------------------------------- /src/qtiles/ui/aboutdialogbase.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | AboutDialogBase 4 | 5 | 6 | 7 | 0 8 | 0 9 | 652 10 | 512 11 | 12 | 13 | 14 | About {plugin_name} 15 | 16 | 17 | 18 | 12 19 | 20 | 21 | 22 | 23 | QTabWidget::North 24 | 25 | 26 | 0 27 | 28 | 29 | false 30 | 31 | 32 | 33 | Information 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 0 41 | 42 | 43 | 0 44 | 45 | 46 | 0 47 | 48 | 49 | 50 | 51 | true 52 | 53 | 54 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> 55 | <html><head><meta name="qrichtext" content="1" /><style type="text/css"> 56 | p, li { white-space: pre-wrap; } 57 | </style></head><body style=" font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;"> 58 | <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> 59 | 60 | 61 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 62 | 63 | 64 | true 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | License 73 | 74 | 75 | 76 | 0 77 | 78 | 79 | 0 80 | 81 | 82 | 0 83 | 84 | 85 | 0 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | Components 95 | 96 | 97 | 98 | 0 99 | 100 | 101 | 0 102 | 103 | 104 | 0 105 | 106 | 107 | 0 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Contributors 117 | 118 | 119 | 120 | 0 121 | 122 | 123 | 0 124 | 125 | 126 | 0 127 | 128 | 129 | 0 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 9 142 | 143 | 144 | 145 | 146 | 3 147 | 148 | 149 | 150 | 151 | 152 | 16 153 | 75 154 | true 155 | 156 | 157 | 158 | {plugin_name} 159 | 160 | 161 | Qt::AlignCenter 162 | 163 | 164 | 165 | 166 | 167 | 168 | Version {version} 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | Qt::Horizontal 178 | 179 | 180 | 181 | 40 182 | 20 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 0 193 | 194 | 195 | 196 | 197 | 198 | 0 199 | 0 200 | 201 | 202 | 203 | Get involved 204 | 205 | 206 | 207 | 208 | 209 | 210 | Qt::Horizontal 211 | 212 | 213 | QDialogButtonBox::Close 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | button_box 225 | rejected() 226 | AboutDialogBase 227 | reject() 228 | 229 | 230 | 316 231 | 260 232 | 233 | 234 | 286 235 | 274 236 | 237 | 238 | 239 | 240 | button_box 241 | accepted() 242 | AboutDialogBase 243 | accept() 244 | 245 | 246 | 248 247 | 254 248 | 249 | 250 | 157 251 | 274 252 | 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /src/qtiles/aboutdialog.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Dict, Optional 5 | 6 | from qgis.core import QgsSettings 7 | from qgis.PyQt import uic 8 | from qgis.PyQt.QtCore import QT_VERSION_STR, QFile, QLocale, QSize, Qt, QUrl 9 | from qgis.PyQt.QtGui import QDesktopServices, QIcon, QPixmap 10 | from qgis.PyQt.QtWidgets import QDialog, QLabel, QWidget 11 | from qgis.utils import pluginMetadata 12 | 13 | QT_MAJOR_VERSION = int(QT_VERSION_STR.split(".")[0]) 14 | if QT_MAJOR_VERSION < 6: 15 | from qgis.PyQt.QtSvg import QSvgWidget 16 | elif importlib.util.find_spec("qgis.PyQt.QtSvgWidgets"): 17 | from qgis.PyQt.QtSvgWidgets import QSvgWidget 18 | else: 19 | from PyQt6.QtSvgWidgets import QSvgWidget 20 | 21 | CURRENT_PATH = Path(__file__).parent 22 | UI_PATH = Path(__file__).parent / "ui" 23 | RESOURCES_PATH = Path(__file__).parents[1] / "resources" 24 | 25 | if (UI_PATH / "about_dialog_base.ui").exists(): 26 | Ui_AboutDialogBase, _ = uic.loadUiType( 27 | str(UI_PATH / "about_dialog_base.ui") 28 | ) 29 | elif (UI_PATH / "aboutdialogbase.ui").exists(): 30 | Ui_AboutDialogBase, _ = uic.loadUiType(str(UI_PATH / "aboutdialogbase.ui")) 31 | elif (RESOURCES_PATH / "about_dialog_base.ui").exists(): 32 | Ui_AboutDialogBase, _ = uic.loadUiType( 33 | str(RESOURCES_PATH / "about_dialog_base.ui") 34 | ) 35 | elif (CURRENT_PATH / "about_dialog_base.ui").exists(): 36 | Ui_AboutDialogBase, _ = uic.loadUiType( 37 | str(CURRENT_PATH / "about_dialog_base.ui") 38 | ) 39 | elif (UI_PATH / "about_dialog_base.py").exists(): 40 | from .ui.about_dialog_base import ( # type: ignore 41 | Ui_AboutDialogBase, 42 | ) 43 | elif (UI_PATH / "aboutdialogbase.py").exists(): 44 | from .ui.aboutdialogbase import ( # type: ignore 45 | Ui_AboutDialogBase, 46 | ) 47 | elif (UI_PATH / "ui_aboutdialogbase.py").exists(): 48 | from .ui.ui_aboutdialogbase import ( # type: ignore 49 | Ui_AboutDialogBase, 50 | ) 51 | else: 52 | raise ImportError 53 | 54 | 55 | class AboutTab(str, Enum): 56 | Information = "information_tab" 57 | License = "license_tab" 58 | Components = "components_tab" 59 | Contributors = "contributors_tab" 60 | 61 | def __str__(self) -> str: 62 | return str(self.value) 63 | 64 | 65 | class AboutDialog(QDialog, Ui_AboutDialogBase): 66 | def __init__(self, package_name: str, parent: Optional[QWidget] = None): 67 | super().__init__(parent) 68 | self.setupUi(self) 69 | self.__package_name = package_name 70 | 71 | module_spec = importlib.util.find_spec(self.__package_name) 72 | if module_spec and module_spec.origin: 73 | self.__package_path = Path(module_spec.origin).parent 74 | else: 75 | self.__package_path = Path(__file__).parent 76 | 77 | self.tab_widget.setCurrentIndex(0) 78 | 79 | metadata = self.__metadata() 80 | self.__set_icon(metadata) 81 | self.__fill_headers(metadata) 82 | self.__fill_get_involved(metadata) 83 | self.__fill_about(metadata) 84 | self.__fill_license() 85 | self.__fill_components() 86 | self.__fill_contributors() 87 | 88 | def __fill_headers(self, metadata: Dict[str, Optional[str]]) -> None: 89 | plugin_name = metadata["plugin_name"] 90 | assert isinstance(plugin_name, str) 91 | if "NextGIS" not in plugin_name: 92 | plugin_name += self.tr(" by NextGIS") 93 | 94 | self.setWindowTitle(self.windowTitle().format(plugin_name=plugin_name)) 95 | self.plugin_name_label.setText( 96 | self.plugin_name_label.text().format_map(metadata) 97 | ) 98 | self.version_label.setText( 99 | self.version_label.text().format_map(metadata) 100 | ) 101 | 102 | def __set_icon(self, metadata: Dict[str, Optional[str]]) -> None: 103 | if metadata.get("icon_path") is None: 104 | return 105 | 106 | header_size: QSize = self.info_layout.sizeHint() 107 | 108 | icon_path = self.__package_path / str(metadata.get("icon_path")) 109 | svg_icon_path = icon_path.with_suffix(".svg") 110 | 111 | if svg_icon_path.exists(): 112 | icon_widget: QWidget = QSvgWidget(str(svg_icon_path), self) 113 | icon_size: QSize = icon_widget.sizeHint() 114 | else: 115 | pixmap = QPixmap(str(icon_path)) 116 | if pixmap.size().height() > header_size.height(): 117 | pixmap = pixmap.scaled( 118 | header_size.height(), 119 | header_size.height(), 120 | Qt.AspectRatioMode.KeepAspectRatioByExpanding, 121 | ) 122 | 123 | icon_size: QSize = pixmap.size() 124 | 125 | icon_widget = QLabel(self) 126 | icon_widget.setPixmap(pixmap) 127 | icon_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) 128 | 129 | icon_size.scale( 130 | header_size.height(), 131 | header_size.height(), 132 | Qt.AspectRatioMode.KeepAspectRatioByExpanding, 133 | ) 134 | icon_widget.setFixedSize(icon_size) 135 | self.header_layout.insertWidget(0, icon_widget) 136 | 137 | def __fill_get_involved(self, metadata: Dict[str, Optional[str]]) -> None: 138 | file_path = str(self.__package_path / "icons" / "nextgis_logo.svg") 139 | resources_path = ( 140 | f":/plugins/{self.__package_name}/icons/nextgis_logo.svg" 141 | ) 142 | 143 | if QFile(resources_path).exists(): 144 | self.get_involved_button.setIcon(QIcon(resources_path)) 145 | elif QFile(file_path).exists(): 146 | self.get_involved_button.setIcon(QIcon(file_path)) 147 | 148 | self.get_involved_button.clicked.connect( 149 | lambda: QDesktopServices.openUrl( 150 | QUrl(metadata["get_involved_url"]) 151 | ) 152 | ) 153 | 154 | def __fill_about(self, metadata: Dict[str, Optional[str]]) -> None: 155 | self.about_text_browser.setHtml(self.__html(metadata)) 156 | 157 | def __fill_license(self) -> None: 158 | license_path = self.__package_path / "LICENSE" 159 | if not license_path.exists(): 160 | self.tab_widget.removeTab(self.__tab_to_index(AboutTab.License)) 161 | return 162 | 163 | self.license_text_browser.setPlainText(license_path.read_text()) 164 | 165 | def __fill_components(self) -> None: 166 | self.tab_widget.removeTab(self.__tab_to_index(AboutTab.Components)) 167 | 168 | def __fill_contributors(self) -> None: 169 | self.tab_widget.removeTab(self.__tab_to_index(AboutTab.Contributors)) 170 | 171 | def __locale(self) -> str: 172 | override_locale = QgsSettings().value( 173 | "locale/overrideFlag", defaultValue=False, type=bool 174 | ) 175 | if not override_locale: 176 | locale_full_name = QLocale.system().name() 177 | else: 178 | locale_full_name = QgsSettings().value("locale/userLocale", "") 179 | 180 | return locale_full_name[0:2] 181 | 182 | def __metadata(self) -> Dict[str, Optional[str]]: 183 | locale = self.__locale() 184 | speaks_russian = locale in ["be", "kk", "ky", "ru", "uk"] 185 | 186 | def metadata_value(key: str) -> Optional[str]: 187 | value = pluginMetadata(self.__package_name, f"{key}[{locale}]") 188 | if value == "__error__": 189 | value = pluginMetadata(self.__package_name, key) 190 | if value == "__error__": 191 | value = None 192 | return value 193 | 194 | about = metadata_value("about") 195 | assert about is not None 196 | for about_stop_phrase in ( 197 | "Разработан", 198 | "Developed by", 199 | "Développé par", 200 | "Desarrollado por", 201 | "Sviluppato da", 202 | "Desenvolvido por", 203 | ): 204 | if about.find(about_stop_phrase) > 0: 205 | about = about[: about.find(about_stop_phrase)] 206 | 207 | package_name = self.__package_name.replace("qgis_", "") 208 | 209 | main_url = f"https://nextgis.{'ru' if speaks_russian else 'com'}" 210 | utm = f"utm_source=qgis_plugin&utm_medium=about&utm_campaign=constant&utm_term={package_name}&utm_content={locale}" 211 | 212 | return { 213 | "plugin_name": metadata_value("name"), 214 | "version": metadata_value("version"), 215 | "icon_path": metadata_value("icon"), 216 | "description": metadata_value("description"), 217 | "about": about, 218 | "authors": metadata_value("author"), 219 | "video_url": metadata_value("video"), 220 | "homepage_url": metadata_value("homepage"), 221 | "tracker_url": metadata_value("tracker"), 222 | "user_guide_url": metadata_value("user_guide"), 223 | "main_url": main_url, 224 | "data_url": main_url.replace("://", "://data."), 225 | "get_involved_url": f"https://nextgis.com/redirect/{locale}/ak45prp5?{utm}", 226 | "utm": f"?{utm}", 227 | "speaks_russian": str(speaks_russian), 228 | } 229 | 230 | def __html(self, metadata: Dict[str, Optional[str]]) -> str: 231 | report_end = self.tr("REPORT_END") 232 | if report_end == "REPORT_END": 233 | report_end = "" 234 | 235 | titles = { 236 | "developers_title": self.tr("Developers"), 237 | "homepage_title": self.tr("Homepage"), 238 | "user_guide": self.tr("User Guide"), 239 | "report_title": self.tr("Please report bugs at"), 240 | "report_end": report_end, 241 | "bugtracker_title": self.tr("bugtracker"), 242 | "video_title": self.tr("Video with an overview of the plugin"), 243 | "services_title": self.tr("Other helpful services by NextGIS"), 244 | "extracts_title": self.tr( 245 | "Convenient up-to-date data extracts for any place in the world" 246 | ), 247 | "webgis_title": self.tr("Fully featured Web GIS service"), 248 | } 249 | 250 | description = """ 251 |

{description}

252 |

{about}

253 | """ 254 | 255 | if metadata.get("user_guide_url") is not None: 256 | description += '

{user_guide}: {user_guide_url}

' 257 | 258 | description += """ 259 |

{developers_title}: {authors}

260 |

{homepage_title}: {homepage_url}

261 |

{report_title} {bugtracker_title} {report_end}

262 | """ 263 | 264 | if metadata.get("video_url") is not None: 265 | description += '

{video_title}: {video_url}

' 266 | 267 | services = """ 268 |

269 | {services_title}: 270 |

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 | \ 144 | \ 147 |
\ 142 | \ 143 | \ 145 | %s \ 146 |
" 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 | --------------------------------------------------------------------------------