├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ └── publish.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── .vscode
└── extensions.json
├── LICENSE
├── README.md
├── docs
├── Makefile
├── _static
│ ├── Bangkok.pdf
│ └── Madrid.pdf
├── api.md
├── changelog.md
├── cli.md
├── conf.py
├── index.md
├── installation.md
├── make.bat
└── usage.md
├── pyproject.toml
└── src
└── papermap
├── __init__.py
├── __main__.py
├── cli.py
├── constants.py
├── defaults.py
├── papermap.py
├── py.typed
├── tile.py
├── tile_server.py
├── typing.py
└── utils.py
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | # Don't trim trailing whitespace for Markdown
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | # YAML files use 2 spaces for indentation
16 | [*.{yml,yaml}]
17 | indent_size = 2
18 |
19 | # Makefiles use tabs for indentation
20 | [Makefile]
21 | indent_style = tab
22 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto-detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Python Package
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | publish:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout the repo
12 | uses: actions/checkout@v3
13 |
14 | - name: Set up Python
15 | uses: actions/setup-python@v4
16 | with:
17 | python-version: "3.10"
18 |
19 | - name: Install Flit
20 | run: |
21 | python -m pip install flit
22 |
23 | - name: Install Dependencies
24 | run: |
25 | python -m flit install --deps production --symlink
26 |
27 | - name: Publish to PyPI
28 | env:
29 | FLIT_USERNAME: __token__
30 | FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }}
31 | run: |
32 | python -m flit publish
33 |
--------------------------------------------------------------------------------
/.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/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autofix_commit_msg: |
3 | :art: [pre-commit.ci] auto fixes from pre-commit.com hooks
4 |
5 | for more information, see https://pre-commit.ci
6 | autoupdate_schedule: monthly
7 | autoupdate_commit_msg: ":arrow_up: [pre-commit.ci] pre-commit autoupdate"
8 |
9 | default_language_version:
10 | python: python3.10
11 |
12 | repos:
13 | - repo: https://github.com/pre-commit/pre-commit-hooks
14 | rev: v4.4.0
15 | hooks:
16 | - id: check-json
17 | - id: check-toml
18 | - id: check-xml
19 | - id: check-yaml
20 | - id: end-of-file-fixer
21 | - id: trailing-whitespace
22 | - repo: https://github.com/abravalheri/validate-pyproject
23 | rev: v0.12.1
24 | hooks:
25 | - id: validate-pyproject
26 | name: validate pyproject.toml
27 | - repo: https://github.com/python-jsonschema/check-jsonschema
28 | rev: 0.22.0
29 | hooks:
30 | - id: check-github-workflows
31 | name: validate github workflows
32 | - id: check-readthedocs
33 | name: validate readthedocs config
34 | - repo: https://github.com/charliermarsh/ruff-pre-commit
35 | rev: v0.0.256
36 | hooks:
37 | - id: ruff
38 | args:
39 | - --fix
40 | - --exit-non-zero-on-fix
41 | - --show-fixes
42 | - repo: https://github.com/psf/black
43 | rev: 23.1.0
44 | hooks:
45 | - id: black
46 | - repo: https://github.com/pre-commit/mirrors-mypy
47 | rev: v1.1.1
48 | hooks:
49 | - id: mypy
50 | additional_dependencies:
51 | - types-requests
52 | - repo: https://github.com/codespell-project/codespell
53 | rev: v2.2.4
54 | hooks:
55 | - id: codespell
56 | - repo: https://github.com/pre-commit/mirrors-prettier
57 | rev: v2.7.1
58 | hooks:
59 | - id: prettier
60 | additional_dependencies:
61 | - prettier@2.8.4
62 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-22.04
5 | tools:
6 | python: "3.10"
7 |
8 | sphinx:
9 | configuration: docs/conf.py
10 |
11 | python:
12 | install:
13 | - method: pip
14 | path: .
15 | extra_requirements:
16 | - docs
17 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["editorconfig.editorconfig"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | PaperMap
2 | Copyright (C) 2019 Steven van de Graaf
3 |
4 | This program is free software: you can redistribute it and/or modify
5 | it under the terms of the GNU General Public License as published by
6 | the Free Software Foundation, either version 3 of the License, or
7 | (at your option) any later version.
8 |
9 | This program is distributed in the hope that it will be useful,
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | GNU General Public License for more details.
13 |
14 | You should have received a copy of the GNU General Public License
15 | along with this program. If not, see .
16 |
17 | ------------------------------------------------------------------------
18 |
19 | GNU GENERAL PUBLIC LICENSE
20 | Version 3, 29 June 2007
21 |
22 | Copyright (C) 2007 Free Software Foundation, Inc.
23 | Everyone is permitted to copy and distribute verbatim copies
24 | of this license document, but changing it is not allowed.
25 |
26 | Preamble
27 |
28 | The GNU General Public License is a free, copyleft license for
29 | software and other kinds of works.
30 |
31 | The licenses for most software and other practical works are designed
32 | to take away your freedom to share and change the works. By contrast,
33 | the GNU General Public License is intended to guarantee your freedom to
34 | share and change all versions of a program--to make sure it remains free
35 | software for all its users. We, the Free Software Foundation, use the
36 | GNU General Public License for most of our software; it applies also to
37 | any other work released this way by its authors. You can apply it to
38 | your programs, too.
39 |
40 | When we speak of free software, we are referring to freedom, not
41 | price. Our General Public Licenses are designed to make sure that you
42 | have the freedom to distribute copies of free software (and charge for
43 | them if you wish), that you receive source code or can get it if you
44 | want it, that you can change the software or use pieces of it in new
45 | free programs, and that you know you can do these things.
46 |
47 | To protect your rights, we need to prevent others from denying you
48 | these rights or asking you to surrender the rights. Therefore, you have
49 | certain responsibilities if you distribute copies of the software, or if
50 | you modify it: responsibilities to respect the freedom of others.
51 |
52 | For example, if you distribute copies of such a program, whether
53 | gratis or for a fee, you must pass on to the recipients the same
54 | freedoms that you received. You must make sure that they, too, receive
55 | or can get the source code. And you must show them these terms so they
56 | know their rights.
57 |
58 | Developers that use the GNU GPL protect your rights with two steps:
59 | (1) assert copyright on the software, and (2) offer you this License
60 | giving you legal permission to copy, distribute and/or modify it.
61 |
62 | For the developers' and authors' protection, the GPL clearly explains
63 | that there is no warranty for this free software. For both users' and
64 | authors' sake, the GPL requires that modified versions be marked as
65 | changed, so that their problems will not be attributed erroneously to
66 | authors of previous versions.
67 |
68 | Some devices are designed to deny users access to install or run
69 | modified versions of the software inside them, although the manufacturer
70 | can do so. This is fundamentally incompatible with the aim of
71 | protecting users' freedom to change the software. The systematic
72 | pattern of such abuse occurs in the area of products for individuals to
73 | use, which is precisely where it is most unacceptable. Therefore, we
74 | have designed this version of the GPL to prohibit the practice for those
75 | products. If such problems arise substantially in other domains, we
76 | stand ready to extend this provision to those domains in future versions
77 | of the GPL, as needed to protect the freedom of users.
78 |
79 | Finally, every program is threatened constantly by software patents.
80 | States should not allow patents to restrict development and use of
81 | software on general-purpose computers, but in those that do, we wish to
82 | avoid the special danger that patents applied to a free program could
83 | make it effectively proprietary. To prevent this, the GPL assures that
84 | patents cannot be used to render the program non-free.
85 |
86 | The precise terms and conditions for copying, distribution and
87 | modification follow.
88 |
89 | TERMS AND CONDITIONS
90 |
91 | 0. Definitions.
92 |
93 | "This License" refers to version 3 of the GNU General Public License.
94 |
95 | "Copyright" also means copyright-like laws that apply to other kinds of
96 | works, such as semiconductor masks.
97 |
98 | "The Program" refers to any copyrightable work licensed under this
99 | License. Each licensee is addressed as "you". "Licensees" and
100 | "recipients" may be individuals or organizations.
101 |
102 | To "modify" a work means to copy from or adapt all or part of the work
103 | in a fashion requiring copyright permission, other than the making of an
104 | exact copy. The resulting work is called a "modified version" of the
105 | earlier work or a work "based on" the earlier work.
106 |
107 | A "covered work" means either the unmodified Program or a work based
108 | on the Program.
109 |
110 | To "propagate" a work means to do anything with it that, without
111 | permission, would make you directly or secondarily liable for
112 | infringement under applicable copyright law, except executing it on a
113 | computer or modifying a private copy. Propagation includes copying,
114 | distribution (with or without modification), making available to the
115 | public, and in some countries other activities as well.
116 |
117 | To "convey" a work means any kind of propagation that enables other
118 | parties to make or receive copies. Mere interaction with a user through
119 | a computer network, with no transfer of a copy, is not conveying.
120 |
121 | An interactive user interface displays "Appropriate Legal Notices"
122 | to the extent that it includes a convenient and prominently visible
123 | feature that (1) displays an appropriate copyright notice, and (2)
124 | tells the user that there is no warranty for the work (except to the
125 | extent that warranties are provided), that licensees may convey the
126 | work under this License, and how to view a copy of this License. If
127 | the interface presents a list of user commands or options, such as a
128 | menu, a prominent item in the list meets this criterion.
129 |
130 | 1. Source Code.
131 |
132 | The "source code" for a work means the preferred form of the work
133 | for making modifications to it. "Object code" means any non-source
134 | form of a work.
135 |
136 | A "Standard Interface" means an interface that either is an official
137 | standard defined by a recognized standards body, or, in the case of
138 | interfaces specified for a particular programming language, one that
139 | is widely used among developers working in that language.
140 |
141 | The "System Libraries" of an executable work include anything, other
142 | than the work as a whole, that (a) is included in the normal form of
143 | packaging a Major Component, but which is not part of that Major
144 | Component, and (b) serves only to enable use of the work with that
145 | Major Component, or to implement a Standard Interface for which an
146 | implementation is available to the public in source code form. A
147 | "Major Component", in this context, means a major essential component
148 | (kernel, window system, and so on) of the specific operating system
149 | (if any) on which the executable work runs, or a compiler used to
150 | produce the work, or an object code interpreter used to run it.
151 |
152 | The "Corresponding Source" for a work in object code form means all
153 | the source code needed to generate, install, and (for an executable
154 | work) run the object code and to modify the work, including scripts to
155 | control those activities. However, it does not include the work's
156 | System Libraries, or general-purpose tools or generally available free
157 | programs which are used unmodified in performing those activities but
158 | which are not part of the work. For example, Corresponding Source
159 | includes interface definition files associated with source files for
160 | the work, and the source code for shared libraries and dynamically
161 | linked subprograms that the work is specifically designed to require,
162 | such as by intimate data communication or control flow between those
163 | subprograms and other parts of the work.
164 |
165 | The Corresponding Source need not include anything that users
166 | can regenerate automatically from other parts of the Corresponding
167 | Source.
168 |
169 | The Corresponding Source for a work in source code form is that
170 | same work.
171 |
172 | 2. Basic Permissions.
173 |
174 | All rights granted under this License are granted for the term of
175 | copyright on the Program, and are irrevocable provided the stated
176 | conditions are met. This License explicitly affirms your unlimited
177 | permission to run the unmodified Program. The output from running a
178 | covered work is covered by this License only if the output, given its
179 | content, constitutes a covered work. This License acknowledges your
180 | rights of fair use or other equivalent, as provided by copyright law.
181 |
182 | You may make, run and propagate covered works that you do not
183 | convey, without conditions so long as your license otherwise remains
184 | in force. You may convey covered works to others for the sole purpose
185 | of having them make modifications exclusively for you, or provide you
186 | with facilities for running those works, provided that you comply with
187 | the terms of this License in conveying all material for which you do
188 | not control copyright. Those thus making or running the covered works
189 | for you must do so exclusively on your behalf, under your direction
190 | and control, on terms that prohibit them from making any copies of
191 | your copyrighted material outside their relationship with you.
192 |
193 | Conveying under any other circumstances is permitted solely under
194 | the conditions stated below. Sublicensing is not allowed; section 10
195 | makes it unnecessary.
196 |
197 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
198 |
199 | No covered work shall be deemed part of an effective technological
200 | measure under any applicable law fulfilling obligations under article
201 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
202 | similar laws prohibiting or restricting circumvention of such
203 | measures.
204 |
205 | When you convey a covered work, you waive any legal power to forbid
206 | circumvention of technological measures to the extent such circumvention
207 | is effected by exercising rights under this License with respect to
208 | the covered work, and you disclaim any intention to limit operation or
209 | modification of the work as a means of enforcing, against the work's
210 | users, your or third parties' legal rights to forbid circumvention of
211 | technological measures.
212 |
213 | 4. Conveying Verbatim Copies.
214 |
215 | You may convey verbatim copies of the Program's source code as you
216 | receive it, in any medium, provided that you conspicuously and
217 | appropriately publish on each copy an appropriate copyright notice;
218 | keep intact all notices stating that this License and any
219 | non-permissive terms added in accord with section 7 apply to the code;
220 | keep intact all notices of the absence of any warranty; and give all
221 | recipients a copy of this License along with the Program.
222 |
223 | You may charge any price or no price for each copy that you convey,
224 | and you may offer support or warranty protection for a fee.
225 |
226 | 5. Conveying Modified Source Versions.
227 |
228 | You may convey a work based on the Program, or the modifications to
229 | produce it from the Program, in the form of source code under the
230 | terms of section 4, provided that you also meet all of these conditions:
231 |
232 | a) The work must carry prominent notices stating that you modified
233 | it, and giving a relevant date.
234 |
235 | b) The work must carry prominent notices stating that it is
236 | released under this License and any conditions added under section
237 | 7. This requirement modifies the requirement in section 4 to
238 | "keep intact all notices".
239 |
240 | c) You must license the entire work, as a whole, under this
241 | License to anyone who comes into possession of a copy. This
242 | License will therefore apply, along with any applicable section 7
243 | additional terms, to the whole of the work, and all its parts,
244 | regardless of how they are packaged. This License gives no
245 | permission to license the work in any other way, but it does not
246 | invalidate such permission if you have separately received it.
247 |
248 | d) If the work has interactive user interfaces, each must display
249 | Appropriate Legal Notices; however, if the Program has interactive
250 | interfaces that do not display Appropriate Legal Notices, your
251 | work need not make them do so.
252 |
253 | A compilation of a covered work with other separate and independent
254 | works, which are not by their nature extensions of the covered work,
255 | and which are not combined with it such as to form a larger program,
256 | in or on a volume of a storage or distribution medium, is called an
257 | "aggregate" if the compilation and its resulting copyright are not
258 | used to limit the access or legal rights of the compilation's users
259 | beyond what the individual works permit. Inclusion of a covered work
260 | in an aggregate does not cause this License to apply to the other
261 | parts of the aggregate.
262 |
263 | 6. Conveying Non-Source Forms.
264 |
265 | You may convey a covered work in object code form under the terms
266 | of sections 4 and 5, provided that you also convey the
267 | machine-readable Corresponding Source under the terms of this License,
268 | in one of these ways:
269 |
270 | a) Convey the object code in, or embodied in, a physical product
271 | (including a physical distribution medium), accompanied by the
272 | Corresponding Source fixed on a durable physical medium
273 | customarily used for software interchange.
274 |
275 | b) Convey the object code in, or embodied in, a physical product
276 | (including a physical distribution medium), accompanied by a
277 | written offer, valid for at least three years and valid for as
278 | long as you offer spare parts or customer support for that product
279 | model, to give anyone who possesses the object code either (1) a
280 | copy of the Corresponding Source for all the software in the
281 | product that is covered by this License, on a durable physical
282 | medium customarily used for software interchange, for a price no
283 | more than your reasonable cost of physically performing this
284 | conveying of source, or (2) access to copy the
285 | Corresponding Source from a network server at no charge.
286 |
287 | c) Convey individual copies of the object code with a copy of the
288 | written offer to provide the Corresponding Source. This
289 | alternative is allowed only occasionally and noncommercially, and
290 | only if you received the object code with such an offer, in accord
291 | with subsection 6b.
292 |
293 | d) Convey the object code by offering access from a designated
294 | place (gratis or for a charge), and offer equivalent access to the
295 | Corresponding Source in the same way through the same place at no
296 | further charge. You need not require recipients to copy the
297 | Corresponding Source along with the object code. If the place to
298 | copy the object code is a network server, the Corresponding Source
299 | may be on a different server (operated by you or a third party)
300 | that supports equivalent copying facilities, provided you maintain
301 | clear directions next to the object code saying where to find the
302 | Corresponding Source. Regardless of what server hosts the
303 | Corresponding Source, you remain obligated to ensure that it is
304 | available for as long as needed to satisfy these requirements.
305 |
306 | e) Convey the object code using peer-to-peer transmission, provided
307 | you inform other peers where the object code and Corresponding
308 | Source of the work are being offered to the general public at no
309 | charge under subsection 6d.
310 |
311 | A separable portion of the object code, whose source code is excluded
312 | from the Corresponding Source as a System Library, need not be
313 | included in conveying the object code work.
314 |
315 | A "User Product" is either (1) a "consumer product", which means any
316 | tangible personal property which is normally used for personal, family,
317 | or household purposes, or (2) anything designed or sold for incorporation
318 | into a dwelling. In determining whether a product is a consumer product,
319 | doubtful cases shall be resolved in favor of coverage. For a particular
320 | product received by a particular user, "normally used" refers to a
321 | typical or common use of that class of product, regardless of the status
322 | of the particular user or of the way in which the particular user
323 | actually uses, or expects or is expected to use, the product. A product
324 | is a consumer product regardless of whether the product has substantial
325 | commercial, industrial or non-consumer uses, unless such uses represent
326 | the only significant mode of use of the product.
327 |
328 | "Installation Information" for a User Product means any methods,
329 | procedures, authorization keys, or other information required to install
330 | and execute modified versions of a covered work in that User Product from
331 | a modified version of its Corresponding Source. The information must
332 | suffice to ensure that the continued functioning of the modified object
333 | code is in no case prevented or interfered with solely because
334 | modification has been made.
335 |
336 | If you convey an object code work under this section in, or with, or
337 | specifically for use in, a User Product, and the conveying occurs as
338 | part of a transaction in which the right of possession and use of the
339 | User Product is transferred to the recipient in perpetuity or for a
340 | fixed term (regardless of how the transaction is characterized), the
341 | Corresponding Source conveyed under this section must be accompanied
342 | by the Installation Information. But this requirement does not apply
343 | if neither you nor any third party retains the ability to install
344 | modified object code on the User Product (for example, the work has
345 | been installed in ROM).
346 |
347 | The requirement to provide Installation Information does not include a
348 | requirement to continue to provide support service, warranty, or updates
349 | for a work that has been modified or installed by the recipient, or for
350 | the User Product in which it has been modified or installed. Access to a
351 | network may be denied when the modification itself materially and
352 | adversely affects the operation of the network or violates the rules and
353 | protocols for communication across the network.
354 |
355 | Corresponding Source conveyed, and Installation Information provided,
356 | in accord with this section must be in a format that is publicly
357 | documented (and with an implementation available to the public in
358 | source code form), and must require no special password or key for
359 | unpacking, reading or copying.
360 |
361 | 7. Additional Terms.
362 |
363 | "Additional permissions" are terms that supplement the terms of this
364 | License by making exceptions from one or more of its conditions.
365 | Additional permissions that are applicable to the entire Program shall
366 | be treated as though they were included in this License, to the extent
367 | that they are valid under applicable law. If additional permissions
368 | apply only to part of the Program, that part may be used separately
369 | under those permissions, but the entire Program remains governed by
370 | this License without regard to the additional permissions.
371 |
372 | When you convey a copy of a covered work, you may at your option
373 | remove any additional permissions from that copy, or from any part of
374 | it. (Additional permissions may be written to require their own
375 | removal in certain cases when you modify the work.) You may place
376 | additional permissions on material, added by you to a covered work,
377 | for which you have or can give appropriate copyright permission.
378 |
379 | Notwithstanding any other provision of this License, for material you
380 | add to a covered work, you may (if authorized by the copyright holders of
381 | that material) supplement the terms of this License with terms:
382 |
383 | a) Disclaiming warranty or limiting liability differently from the
384 | terms of sections 15 and 16 of this License; or
385 |
386 | b) Requiring preservation of specified reasonable legal notices or
387 | author attributions in that material or in the Appropriate Legal
388 | Notices displayed by works containing it; or
389 |
390 | c) Prohibiting misrepresentation of the origin of that material, or
391 | requiring that modified versions of such material be marked in
392 | reasonable ways as different from the original version; or
393 |
394 | d) Limiting the use for publicity purposes of names of licensors or
395 | authors of the material; or
396 |
397 | e) Declining to grant rights under trademark law for use of some
398 | trade names, trademarks, or service marks; or
399 |
400 | f) Requiring indemnification of licensors and authors of that
401 | material by anyone who conveys the material (or modified versions of
402 | it) with contractual assumptions of liability to the recipient, for
403 | any liability that these contractual assumptions directly impose on
404 | those licensors and authors.
405 |
406 | All other non-permissive additional terms are considered "further
407 | restrictions" within the meaning of section 10. If the Program as you
408 | received it, or any part of it, contains a notice stating that it is
409 | governed by this License along with a term that is a further
410 | restriction, you may remove that term. If a license document contains
411 | a further restriction but permits relicensing or conveying under this
412 | License, you may add to a covered work material governed by the terms
413 | of that license document, provided that the further restriction does
414 | not survive such relicensing or conveying.
415 |
416 | If you add terms to a covered work in accord with this section, you
417 | must place, in the relevant source files, a statement of the
418 | additional terms that apply to those files, or a notice indicating
419 | where to find the applicable terms.
420 |
421 | Additional terms, permissive or non-permissive, may be stated in the
422 | form of a separately written license, or stated as exceptions;
423 | the above requirements apply either way.
424 |
425 | 8. Termination.
426 |
427 | You may not propagate or modify a covered work except as expressly
428 | provided under this License. Any attempt otherwise to propagate or
429 | modify it is void, and will automatically terminate your rights under
430 | this License (including any patent licenses granted under the third
431 | paragraph of section 11).
432 |
433 | However, if you cease all violation of this License, then your
434 | license from a particular copyright holder is reinstated (a)
435 | provisionally, unless and until the copyright holder explicitly and
436 | finally terminates your license, and (b) permanently, if the copyright
437 | holder fails to notify you of the violation by some reasonable means
438 | prior to 60 days after the cessation.
439 |
440 | Moreover, your license from a particular copyright holder is
441 | reinstated permanently if the copyright holder notifies you of the
442 | violation by some reasonable means, this is the first time you have
443 | received notice of violation of this License (for any work) from that
444 | copyright holder, and you cure the violation prior to 30 days after
445 | your receipt of the notice.
446 |
447 | Termination of your rights under this section does not terminate the
448 | licenses of parties who have received copies or rights from you under
449 | this License. If your rights have been terminated and not permanently
450 | reinstated, you do not qualify to receive new licenses for the same
451 | material under section 10.
452 |
453 | 9. Acceptance Not Required for Having Copies.
454 |
455 | You are not required to accept this License in order to receive or
456 | run a copy of the Program. Ancillary propagation of a covered work
457 | occurring solely as a consequence of using peer-to-peer transmission
458 | to receive a copy likewise does not require acceptance. However,
459 | nothing other than this License grants you permission to propagate or
460 | modify any covered work. These actions infringe copyright if you do
461 | not accept this License. Therefore, by modifying or propagating a
462 | covered work, you indicate your acceptance of this License to do so.
463 |
464 | 10. Automatic Licensing of Downstream Recipients.
465 |
466 | Each time you convey a covered work, the recipient automatically
467 | receives a license from the original licensors, to run, modify and
468 | propagate that work, subject to this License. You are not responsible
469 | for enforcing compliance by third parties with this License.
470 |
471 | An "entity transaction" is a transaction transferring control of an
472 | organization, or substantially all assets of one, or subdividing an
473 | organization, or merging organizations. If propagation of a covered
474 | work results from an entity transaction, each party to that
475 | transaction who receives a copy of the work also receives whatever
476 | licenses to the work the party's predecessor in interest had or could
477 | give under the previous paragraph, plus a right to possession of the
478 | Corresponding Source of the work from the predecessor in interest, if
479 | the predecessor has it or can get it with reasonable efforts.
480 |
481 | You may not impose any further restrictions on the exercise of the
482 | rights granted or affirmed under this License. For example, you may
483 | not impose a license fee, royalty, or other charge for exercise of
484 | rights granted under this License, and you may not initiate litigation
485 | (including a cross-claim or counterclaim in a lawsuit) alleging that
486 | any patent claim is infringed by making, using, selling, offering for
487 | sale, or importing the Program or any portion of it.
488 |
489 | 11. Patents.
490 |
491 | A "contributor" is a copyright holder who authorizes use under this
492 | License of the Program or a work on which the Program is based. The
493 | work thus licensed is called the contributor's "contributor version".
494 |
495 | A contributor's "essential patent claims" are all patent claims
496 | owned or controlled by the contributor, whether already acquired or
497 | hereafter acquired, that would be infringed by some manner, permitted
498 | by this License, of making, using, or selling its contributor version,
499 | but do not include claims that would be infringed only as a
500 | consequence of further modification of the contributor version. For
501 | purposes of this definition, "control" includes the right to grant
502 | patent sublicenses in a manner consistent with the requirements of
503 | this License.
504 |
505 | Each contributor grants you a non-exclusive, worldwide, royalty-free
506 | patent license under the contributor's essential patent claims, to
507 | make, use, sell, offer for sale, import and otherwise run, modify and
508 | propagate the contents of its contributor version.
509 |
510 | In the following three paragraphs, a "patent license" is any express
511 | agreement or commitment, however denominated, not to enforce a patent
512 | (such as an express permission to practice a patent or covenant not to
513 | sue for patent infringement). To "grant" such a patent license to a
514 | party means to make such an agreement or commitment not to enforce a
515 | patent against the party.
516 |
517 | If you convey a covered work, knowingly relying on a patent license,
518 | and the Corresponding Source of the work is not available for anyone
519 | to copy, free of charge and under the terms of this License, through a
520 | publicly available network server or other readily accessible means,
521 | then you must either (1) cause the Corresponding Source to be so
522 | available, or (2) arrange to deprive yourself of the benefit of the
523 | patent license for this particular work, or (3) arrange, in a manner
524 | consistent with the requirements of this License, to extend the patent
525 | license to downstream recipients. "Knowingly relying" means you have
526 | actual knowledge that, but for the patent license, your conveying the
527 | covered work in a country, or your recipient's use of the covered work
528 | in a country, would infringe one or more identifiable patents in that
529 | country that you have reason to believe are valid.
530 |
531 | If, pursuant to or in connection with a single transaction or
532 | arrangement, you convey, or propagate by procuring conveyance of, a
533 | covered work, and grant a patent license to some of the parties
534 | receiving the covered work authorizing them to use, propagate, modify
535 | or convey a specific copy of the covered work, then the patent license
536 | you grant is automatically extended to all recipients of the covered
537 | work and works based on it.
538 |
539 | A patent license is "discriminatory" if it does not include within
540 | the scope of its coverage, prohibits the exercise of, or is
541 | conditioned on the non-exercise of one or more of the rights that are
542 | specifically granted under this License. You may not convey a covered
543 | work if you are a party to an arrangement with a third party that is
544 | in the business of distributing software, under which you make payment
545 | to the third party based on the extent of your activity of conveying
546 | the work, and under which the third party grants, to any of the
547 | parties who would receive the covered work from you, a discriminatory
548 | patent license (a) in connection with copies of the covered work
549 | conveyed by you (or copies made from those copies), or (b) primarily
550 | for and in connection with specific products or compilations that
551 | contain the covered work, unless you entered into that arrangement,
552 | or that patent license was granted, prior to 28 March 2007.
553 |
554 | Nothing in this License shall be construed as excluding or limiting
555 | any implied license or other defenses to infringement that may
556 | otherwise be available to you under applicable patent law.
557 |
558 | 12. No Surrender of Others' Freedom.
559 |
560 | If conditions are imposed on you (whether by court order, agreement or
561 | otherwise) that contradict the conditions of this License, they do not
562 | excuse you from the conditions of this License. If you cannot convey a
563 | covered work so as to satisfy simultaneously your obligations under this
564 | License and any other pertinent obligations, then as a consequence you may
565 | not convey it at all. For example, if you agree to terms that obligate you
566 | to collect a royalty for further conveying from those to whom you convey
567 | the Program, the only way you could satisfy both those terms and this
568 | License would be to refrain entirely from conveying the Program.
569 |
570 | 13. Use with the GNU Affero General Public License.
571 |
572 | Notwithstanding any other provision of this License, you have
573 | permission to link or combine any covered work with a work licensed
574 | under version 3 of the GNU Affero General Public License into a single
575 | combined work, and to convey the resulting work. The terms of this
576 | License will continue to apply to the part which is the covered work,
577 | but the special requirements of the GNU Affero General Public License,
578 | section 13, concerning interaction through a network will apply to the
579 | combination as such.
580 |
581 | 14. Revised Versions of this License.
582 |
583 | The Free Software Foundation may publish revised and/or new versions of
584 | the GNU General Public License from time to time. Such new versions will
585 | be similar in spirit to the present version, but may differ in detail to
586 | address new problems or concerns.
587 |
588 | Each version is given a distinguishing version number. If the
589 | Program specifies that a certain numbered version of the GNU General
590 | Public License "or any later version" applies to it, you have the
591 | option of following the terms and conditions either of that numbered
592 | version or of any later version published by the Free Software
593 | Foundation. If the Program does not specify a version number of the
594 | GNU General Public License, you may choose any version ever published
595 | by the Free Software Foundation.
596 |
597 | If the Program specifies that a proxy can decide which future
598 | versions of the GNU General Public License can be used, that proxy's
599 | public statement of acceptance of a version permanently authorizes you
600 | to choose that version for the Program.
601 |
602 | Later license versions may give you additional or different
603 | permissions. However, no additional obligations are imposed on any
604 | author or copyright holder as a result of your choosing to follow a
605 | later version.
606 |
607 | 15. Disclaimer of Warranty.
608 |
609 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
610 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
611 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
612 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
613 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
614 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
615 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
616 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
617 |
618 | 16. Limitation of Liability.
619 |
620 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
621 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
622 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
623 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
624 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
625 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
626 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
627 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
628 | SUCH DAMAGES.
629 |
630 | 17. Interpretation of Sections 15 and 16.
631 |
632 | If the disclaimer of warranty and limitation of liability provided
633 | above cannot be given local legal effect according to their terms,
634 | reviewing courts shall apply local law that most closely approximates
635 | an absolute waiver of all civil liability in connection with the
636 | Program, unless a warranty or assumption of liability accompanies a
637 | copy of the Program in return for a fee.
638 |
639 | END OF TERMS AND CONDITIONS
640 |
641 | How to Apply These Terms to Your New Programs
642 |
643 | If you develop a new program, and you want it to be of the greatest
644 | possible use to the public, the best way to achieve this is to make it
645 | free software which everyone can redistribute and change under these terms.
646 |
647 | To do so, attach the following notices to the program. It is safest
648 | to attach them to the start of each source file to most effectively
649 | state the exclusion of warranty; and each file should have at least
650 | the "copyright" line and a pointer to where the full notice is found.
651 |
652 |
653 | Copyright (C)
654 |
655 | This program is free software: you can redistribute it and/or modify
656 | it under the terms of the GNU General Public License as published by
657 | the Free Software Foundation, either version 3 of the License, or
658 | (at your option) any later version.
659 |
660 | This program is distributed in the hope that it will be useful,
661 | but WITHOUT ANY WARRANTY; without even the implied warranty of
662 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
663 | GNU General Public License for more details.
664 |
665 | You should have received a copy of the GNU General Public License
666 | along with this program. If not, see .
667 |
668 | Also add information on how to contact you by electronic and paper mail.
669 |
670 | If the program does terminal interaction, make it output a short
671 | notice like this when it starts in an interactive mode:
672 |
673 | Copyright (C)
674 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
675 | This is free software, and you are welcome to redistribute it
676 | under certain conditions; type `show c' for details.
677 |
678 | The hypothetical commands `show w' and `show c' should show the appropriate
679 | parts of the General Public License. Of course, your program's commands
680 | might be different; for a GUI interface, you would use an "about box".
681 |
682 | You should also get your employer (if you work as a programmer) or school,
683 | if any, to sign a "copyright disclaimer" for the program, if necessary.
684 | For more information on this, and how to apply and follow the GNU GPL, see
685 | .
686 |
687 | The GNU General Public License does not permit incorporating your program
688 | into proprietary programs. If your program is a subroutine library, you
689 | may consider it more useful to permit linking proprietary applications with
690 | the library. If this is what you want to do, use the GNU Lesser General
691 | Public License instead of this License. But first, please read
692 | .
693 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # PaperMap
4 |
5 | [](https://img.shields.io/pypi/v/papermap)
6 | [](https://pypi.org/project/papermap/)
7 | [](https://results.pre-commit.ci/latest/github/sgraaf/papermap/main)
8 | [](https://github.com/sgraaf/papermap/actions/workflows/test.yml)
9 | [](https://papermap.readthedocs.io/en/latest/?badge=latest)
10 | [](https://img.shields.io/pypi/l/papermap)
11 |
12 | PaperMap is a Python package and CLI for creating ready-to-print paper maps.
13 |
14 |
15 |
16 | ## Installation
17 |
18 |
19 |
20 | ### From PyPI
21 |
22 | PaperMap is available on [PyPI](https://pypi.org/project/papermap/).
23 |
24 | #### As a package
25 |
26 | For use as a package, install PaperMap with `pip` or your package manager of choice:
27 |
28 | ```bash
29 | pip install papermap
30 | ```
31 |
32 | #### As a CLI tool
33 |
34 | For use as a CLI tool, we recommend installing PaperMap with [`pipx`](https://pypa.github.io/pipx/):
35 |
36 | ```bash
37 | pipx install papermap
38 | ```
39 |
40 | ### From source
41 |
42 | If you'd like, you can also install PaperMap from source (with [`flit`](https://flit.readthedocs.io/en/latest/)):
43 |
44 | ```bash
45 | git clone https://github.com/sgraaf/papermap.git
46 | cd papermap
47 | python3 -m pip install flit
48 | flit install
49 | ```
50 |
51 |
52 |
53 | ## Documentation
54 |
55 | Check out the [PaperMap documentation](https://papermap.readthedocs.io/en/stable/) for the [User's Guide](https://papermap.readthedocs.io/en/stable/usage.html) and [API Reference](https://papermap.readthedocs.io/en/stable/api.html).
56 |
57 | ## Usage
58 |
59 | PaperMap can be used both in your own applications as a package, as well as a CLI tool.
60 |
61 | #### As a package
62 |
63 | Using the default values, the example below will create an portrait-oriented, A4-sized map of Bangkok at scale 1:25000:
64 |
65 | ```python
66 | >>> from papermap import PaperMap
67 | >>> pm = PaperMap(13.75889, 100.49722)
68 | >>> pm.render()
69 | >>> pm.save("Bangkok.pdf")
70 | ```
71 |
72 | You can easily customize the generated map by changing the tile server, size, orientation, etc. For an exhaustive list of all available options, please see the [API Reference](https://papermap.readthedocs.io/en/stable/api.html#papermap.papermap.PaperMap).
73 |
74 | For example, the example below will create a landscape-oriented, A3-sized map of Madrid using the [Stamen Terrain](https://stamen.com/say-hello-to-global-stamen-terrain-maps-c195b3bb71e0/) tile server, with a UTM grid overlay, at scale 1:50000:
75 |
76 | ```python
77 | >>> from papermap import PaperMap
78 | >>> pm = PaperMap(
79 | ... lat=40.416775,
80 | ... lon=-3.703790,
81 | ... tile_server="Stamen Terrain",
82 | ... size="a3",
83 | ... landscape=True,
84 | ... scale=50_000,
85 | ... add_grid=True,
86 | >>> )
87 | >>> pm.render()
88 | >>> pm.save("Madrid.pdf")
89 | ```
90 |
91 | #### As a CLI tool
92 |
93 | Similarly, using the default values, the example below will create an portrait-oriented, A4-sized map of Bangkok at scale 1:25000:
94 |
95 | ```shell
96 | $ papermap latlon -- 13.75889 100.49722 Bangkok.pdf
97 | ```
98 |
99 | As with the package, maps generated through the CLI are also highly customizable. Please see the [CLI Reference](https://papermap.readthedocs.io/en/stable/cli.html) for an exhaustive list of all available options.
100 |
101 | The example below will create a landscape-oriented, A3-sized map of Madrid using the [Stamen Terrain](https://stamen.com/say-hello-to-global-stamen-terrain-maps-c195b3bb71e0/) tile server, with a UTM grid overlay, at scale 1:50000:
102 |
103 | ```shell
104 | $ papermap latlon \
105 | --tile-server "Stamen Terrain" \
106 | --size a3 \
107 | --landscape \
108 | --scale 50000 \
109 | --grid \
110 | -- 40.416775 -3.703790 Madrid.pdf
111 | ```
112 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
22 | live:
23 | sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
24 |
25 | cog:
26 | cog -r "$(SOURCEDIR)/*.md"
27 |
28 | examples:
29 | papermap latlon -- 13.75889 100.49722 _static/Bangkok.pdf
30 | papermap latlon --tile-server "Stamen Terrain" --size a3 --landscape --scale 50000 --grid -- 40.416775 -3.703790 _static/Madrid.pdf
31 |
--------------------------------------------------------------------------------
/docs/_static/Bangkok.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sgraaf/papermap/5bd250b5c1e2a6f329f01d985dcc8264947084c1/docs/_static/Bangkok.pdf
--------------------------------------------------------------------------------
/docs/_static/Madrid.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sgraaf/papermap/5bd250b5c1e2a6f329f01d985dcc8264947084c1/docs/_static/Madrid.pdf
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | ## `papermap.papermap` Module
4 |
5 | ```{eval-rst}
6 | .. automodule:: papermap.papermap
7 | :members:
8 | ```
9 |
10 | ## `papermap.utils` Module
11 |
12 | ```{eval-rst}
13 | .. automodule:: papermap.utils
14 | :members:
15 | ```
16 |
17 | ## `papermap.tile` Module
18 |
19 | ```{eval-rst}
20 | .. automodule:: papermap.tile
21 | :members:
22 | ```
23 |
24 | ## `papermap.tile_server` Module
25 |
26 | ```{eval-rst}
27 | .. automodule:: papermap.tile_server
28 | :members:
29 | ```
30 |
31 | ## `papermap.typing` Module
32 |
33 | ```{eval-rst}
34 | .. automodule:: papermap.typing
35 | :members:
36 | ```
37 |
--------------------------------------------------------------------------------
/docs/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.3.0 (2022-11-09)
4 |
5 | This is a pretty big release with a completely overhauled codebase. For this, I used my new [cookiecutter-python-package](https://github.com/sgraaf/cookiecutter-python-package) Python package template. As such, this release comes with much higher code quality, documentation, automation and some important changes to the core functionality of PaperMap.
6 |
7 | ### Changes
8 |
9 | - Completely refactored codebase, with:
10 | - Moved source code from `./papermap` to `./src/papermap`
11 | - Switched to [fpdf2](https://pyfpdf.github.io/fpdf2/) for the PDF creation
12 | - Added custom types
13 | - Fully typed
14 | - Added class for tile servers
15 | - Re-implemented spherical-to-UTM conversions
16 | - Removed GPX support (will come back soon via [PyGPX](https://pypi.org/project/gpx/))
17 | - Removed tests (will come back soon via [pytest](https://docs.pytest.org/en/stable/contents.html))
18 | - Added documentation via [Read the Docs](https://readthedocs.org/)
19 | - Added CI/CD via GitHub Actions
20 | - Added [pre-commit hooks](https://pre-commit.com) w/ CI-integration
21 | - Switched to [Click](https://click.palletsprojects.com/en/8.1.x/) for the CLI
22 | - Switched to [flit](https://flit.pypa.io/en/stable/) for building & releasing the package
23 |
24 | ## 0.2.2 (2020-11-26)
25 |
26 | ### Changes
27 |
28 | - Added support for custom fonts
29 |
30 | ## 0.2.1 (2020-11-03)
31 |
32 | ### Changes
33 |
34 | - Added GPX support
35 | - Added more tile servers
36 | - Added tests
37 | - Refactored the codebase
38 |
39 | ## 0.1.0 (2019-10-09)
40 |
41 | ### Changes
42 |
43 | - Initial release of PaperMap
44 |
--------------------------------------------------------------------------------
/docs/cli.md:
--------------------------------------------------------------------------------
1 | # CLI Reference
2 |
3 |
14 |
15 |
16 | The `papermap` CLI tool provides two commands to generate papermaps, which boil down to the two different coordinate systems through which you can define the location that the map should cover.
17 |
18 | Running `papermap` without specifying a command runs the default command, `papermap latlon`. See [papermap latlon](#papermap-latlon) for the full list of options for that command.
19 |
20 | ## papermap --help
21 |
22 |
25 |
26 | ```shell
27 | Usage: papermap [OPTIONS] COMMAND [ARGS]...
28 |
29 | PaperMap is a Python package and CLI for creating ready-to-print paper maps.
30 |
31 | Documentation: https://papermap.readthedocs.io/en/stable/
32 |
33 | Options:
34 | -v, --version Show the version and exit.
35 | -h, --help Show this message and exit.
36 |
37 | Commands:
38 | latlon* Generates a paper map for the given spherical coordinate (i.e.
39 | utm Generates a paper map for the given UTM coordinate and outputs it...
40 | ```
41 |
42 |
43 |
44 | (cli_help_latlon)=
45 |
46 | ## papermap latlon
47 |
48 | This command generates a paper map for the given spherical coordinate (i.e. lat, lon) and outputs it to file.
49 |
50 |
53 |
54 | ```shell
55 | Usage: papermap latlon [OPTIONS] LATITUDE LONGITUDE FILE
56 |
57 | Generates a paper map for the given spherical coordinate (i.e. lat, lon) and
58 | outputs it to file.
59 |
60 | Options:
61 | --tile-server [OpenStreetMap|OpenStreetMap Monochrome|OpenTopoMap|Thunderforest Landscape|Thunderforest Outdoors|Thunderforest Transport|Thunderforest OpenCycleMap|ESRI Standard|ESRI Satellite|ESRI Topo|ESRI Dark Gray|ESRI Light Gray|ESRI Transportation|Geofabrik Topo|Google Maps|Google Maps Satellite|Google Maps Satellite Hybrid|Google Maps Terrain|Google Maps Terrain Hybrid|HERE Terrain|HERE Satellite|HERE Hybrid|Mapy.cz|Stamen Terrain|Stamen Toner|Stamen Toner Lite|Komoot|Wikimedia|Hike & Bike|AllTrails]
62 | Tile server to serve as the base of the paper
63 | map.
64 | --api-key KEY API key for the chosen tile server (if
65 | applicable).
66 | --size [a0|a1|a2|a3|a4|a5|a6|a7|letter|legal]
67 | Size of the paper map.
68 | --landscape Use landscape orientation.
69 | --margin-top MILLIMETERS Top margin.
70 | --margin-right MILLIMETERS Right margin.
71 | --margin-bottom MILLIMETERS Bottom margin.
72 | --margin-left MILLIMETERS Left margin.
73 | --scale INTEGER Scale of the paper map.
74 | --dpi INTEGER Dots per inch.
75 | --grid Add a coordinate grid overlay to the paper
76 | map.
77 | --grid-size METERS Size of the grid squares (if applicable).
78 | -h, --help Show this message and exit.
79 | ```
80 |
81 |
82 |
83 | ## papermap utm
84 |
85 | This command generates a paper map for the given UTM coordinate (i.e. easting, northing, zone) and outputs it to file.
86 |
87 |
90 |
91 | ```shell
92 | Usage: papermap utm [OPTIONS] EASTING NORTHING ZONE-NUMBER HEMISPHERE FILE
93 |
94 | Generates a paper map for the given UTM coordinate and outputs it to file.
95 |
96 | Options:
97 | --tile-server [OpenStreetMap|OpenStreetMap Monochrome|OpenTopoMap|Thunderforest Landscape|Thunderforest Outdoors|Thunderforest Transport|Thunderforest OpenCycleMap|ESRI Standard|ESRI Satellite|ESRI Topo|ESRI Dark Gray|ESRI Light Gray|ESRI Transportation|Geofabrik Topo|Google Maps|Google Maps Satellite|Google Maps Satellite Hybrid|Google Maps Terrain|Google Maps Terrain Hybrid|HERE Terrain|HERE Satellite|HERE Hybrid|Mapy.cz|Stamen Terrain|Stamen Toner|Stamen Toner Lite|Komoot|Wikimedia|Hike & Bike|AllTrails]
98 | Tile server to serve as the base of the paper
99 | map.
100 | --api-key KEY API key for the chosen tile server (if
101 | applicable).
102 | --size [a0|a1|a2|a3|a4|a5|a6|a7|letter|legal]
103 | Size of the paper map.
104 | --landscape Use landscape orientation.
105 | --margin-top MILLIMETERS Top margin.
106 | --margin-right MILLIMETERS Right margin.
107 | --margin-bottom MILLIMETERS Bottom margin.
108 | --margin-left MILLIMETERS Left margin.
109 | --scale INTEGER Scale of the paper map.
110 | --dpi INTEGER Dots per inch.
111 | --grid Add a coordinate grid overlay to the paper
112 | map.
113 | --grid-size METERS Size of the grid squares (if applicable).
114 | -h, --help Show this message and exit.
115 | ```
116 |
117 |
118 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | """Sphinx configuration."""
2 | import os
3 | import sys
4 |
5 | sys.path.insert(0, os.path.abspath("../src"))
6 | import papermap # noqa: E402
7 |
8 | # -- Project information -----------------------------------------------------
9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
10 |
11 | project = "PaperMap"
12 | copyright = "2019, Steven van de Graaf"
13 | author = "Steven van de Graaf"
14 | release = papermap.__version__
15 |
16 | # -- General configuration ---------------------------------------------------
17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
18 |
19 | extensions = [
20 | "myst_parser",
21 | "sphinx.ext.autodoc",
22 | "sphinx.ext.autodoc.typehints",
23 | "sphinx.ext.intersphinx",
24 | "sphinx.ext.napoleon",
25 | "sphinx_copybutton",
26 | "sphinxext.opengraph",
27 | ]
28 |
29 | templates_path = ["_templates"]
30 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
31 |
32 | # auto-generate header anchors
33 | myst_heading_anchors = 3
34 |
35 | intersphinx_mapping = {
36 | "python": ("https://docs.python.org/3", None),
37 | "Pillow": ("https://pillow.readthedocs.io/en/stable/", None),
38 | }
39 |
40 | # move type hints into the description block, instead of the signature
41 | autodoc_typehints = "description"
42 | autodoc_typehints_description_target = "documented"
43 |
44 | # -- Options for HTML output -------------------------------------------------
45 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
46 |
47 | html_theme = "furo"
48 | html_static_path = ["_static"]
49 | html_theme_options = {
50 | "top_of_page_button": None,
51 | }
52 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ```{include} ../README.md
2 | :start-after:
3 | :end-before:
4 | ```
5 |
6 | ## User's Guide
7 |
8 | ```{toctree}
9 | ---
10 | maxdepth: 2
11 | ---
12 |
13 | installation
14 | usage
15 | ```
16 |
17 | ## API Reference
18 |
19 | ```{toctree}
20 | ---
21 | maxdepth: 2
22 | ---
23 |
24 | api
25 | ```
26 |
27 | ## CLI Reference
28 |
29 | ```{toctree}
30 | ---
31 | maxdepth: 2
32 | ---
33 |
34 | cli
35 | ```
36 |
37 | ## Changelog
38 |
39 | ```{toctree}
40 | ---
41 | maxdepth: 2
42 | ---
43 |
44 | changelog
45 | ```
46 |
--------------------------------------------------------------------------------
/docs/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | ```{include} ../README.md
4 | :start-after:
5 | :end-before:
6 | ```
7 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | PaperMap can be used both in your own applications as a package, as well as a CLI tool.
4 |
5 | ## As a package
6 |
7 | Using the default values, the example below will create a portrait-oriented, A4-sized [map of Bangkok](_static/Bangkok.pdf) at scale 1:25000:
8 |
9 | ```python
10 | >>> from papermap import PaperMap
11 | >>> pm = PaperMap(13.75889, 100.49722)
12 | >>> pm.render()
13 | >>> pm.save("Bangkok.pdf")
14 | ```
15 |
16 | You can easily customize the generated map by changing the tile server, size, orientation, etc. For an exhaustive list of all available options, please see the [API Reference](https://papermap.readthedocs.io/en/stable/api.html#papermap.papermap.PaperMap).
17 |
18 | For example, the example below will create a landscape-oriented, A3-sized [map of Madrid](_static/Madrid.pdf) using the Stamen Terrain](https://stamen.com/say-hello-to-global-stamen-terrain-maps-c195b3bb71e0/) tile server, with a UTM grid overlay, at scale 1:50000:
19 |
20 | ```python
21 | >>> from papermap import PaperMap
22 | >>> pm = PaperMap(
23 | ... lat=40.416775,
24 | ... lon=-3.703790,
25 | ... tile_server="Stamen Terrain",
26 | ... size="a3",
27 | ... landscape=True,
28 | ... scale=50_000,
29 | ... add_grid=True,
30 | >>> )
31 | >>> pm.render()
32 | >>> pm.save("Madrid.pdf")
33 | ```
34 |
35 | ## As a CLI tool
36 |
37 | Similarly, using the default values, the example below will create an portrait-oriented, A4-sized [map of Bangkok](_static/Bangkok.pdf) at scale 1:25000:
38 |
39 | ```shell
40 | $ papermap latlon -- 13.75889 100.49722 Bangkok.pdf
41 | ```
42 |
43 | As with the package, maps generated through the CLI are also highly customizable. Please see the [CLI Reference](https://papermap.readthedocs.io/en/stable/cli.html) for an exhaustive list of all available options.
44 |
45 | The example below will create a landscape-oriented, A3-sized [map of Madrid](_static/Madrid.pdf) using the [Stamen Terrain](https://stamen.com/say-hello-to-global-stamen-terrain-maps-c195b3bb71e0/) tile server, with a UTM grid overlay, at scale 1:50000:
46 |
47 | ```shell
48 | $ papermap latlon \
49 | --tile-server "Stamen Terrain" \
50 | --size a3 \
51 | --landscape \
52 | --scale 50000 \
53 | --grid \
54 | -- 40.416775 -3.703790 Madrid.pdf
55 | ```
56 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit_core >=3.2,<4"]
3 | build-backend = "flit_core.buildapi"
4 |
5 | [project]
6 | name = "papermap"
7 | authors = [{name = "Steven van de Graaf", email = "steven@vandegraaf.xyz"}]
8 | readme = "README.md"
9 | license = {file = "LICENSE"}
10 | keywords = ["paper", "map", "topography", "osm", "openstreetmap"]
11 | classifiers = [
12 | "Development Status :: 4 - Beta",
13 | "Intended Audience :: Developers",
14 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
15 | "Operating System :: OS Independent",
16 | "Programming Language :: Python",
17 | "Programming Language :: Python :: 3",
18 | "Programming Language :: Python :: 3.8",
19 | "Programming Language :: Python :: 3.9",
20 | "Programming Language :: Python :: 3.10",
21 | "Programming Language :: Python :: 3.11",
22 | "Typing :: Typed",
23 | ]
24 | requires-python = "~=3.8"
25 | dependencies = [
26 | "click",
27 | "click_default_group",
28 | "fpdf2",
29 | "Pillow",
30 | "requests",
31 | ]
32 | dynamic = ["version", "description"]
33 |
34 | [project.optional-dependencies]
35 | dev = [
36 | "black",
37 | "mypy",
38 | "pre-commit",
39 | "ruff",
40 | ]
41 | docs = [
42 | "furo",
43 | "myst-parser",
44 | "sphinx",
45 | "sphinx-copybutton",
46 | "sphinxext-opengraph",
47 | ]
48 | tests = [
49 | "cog",
50 | "pytest",
51 | ]
52 |
53 | [project.urls]
54 | Documentation = "https://papermap.readthedocs.io/en/stable/"
55 | Changelog = "https://papermap.readthedocs.io/en/stable/changelog.html"
56 | "Source code" = "https://github.com/sgraaf/papermap"
57 | Issues = "https://github.com/sgraaf/papermap/issues"
58 |
59 | [project.scripts]
60 | papermap = "papermap.cli:cli"
61 |
62 | [tool.ruff]
63 | select = [
64 | "B",
65 | "C90",
66 | "E",
67 | "F",
68 | "I",
69 | "UP",
70 | "RUF100",
71 | "W",
72 | ]
73 | ignore = ["E501"]
74 | src = ["src"]
75 | target-version = "py38"
76 |
77 | [tool.ruff.per-file-ignores]
78 | "__init__.py" = ["F401"]
79 |
--------------------------------------------------------------------------------
/src/papermap/__init__.py:
--------------------------------------------------------------------------------
1 | """PaperMap is a Python package and CLI for creating ready-to-print paper maps."""
2 |
3 | __version__ = "0.3.0"
4 |
5 | from .papermap import PaperMap
6 |
--------------------------------------------------------------------------------
/src/papermap/__main__.py:
--------------------------------------------------------------------------------
1 | from .cli import cli
2 |
3 | if __name__ == "__main__":
4 | cli()
5 |
--------------------------------------------------------------------------------
/src/papermap/cli.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from pathlib import Path
3 | from typing import Callable, Optional
4 |
5 | import click
6 | from click_default_group import DefaultGroup
7 |
8 | from . import __version__
9 | from .defaults import (
10 | DEFAULT_DPI,
11 | DEFAULT_GRID_SIZE,
12 | DEFAULT_MARGIN,
13 | DEFAULT_SCALE,
14 | DEFAULT_SIZE,
15 | DEFAULT_TILE_SERVER,
16 | SIZES,
17 | TILE_SERVERS,
18 | )
19 | from .papermap import PaperMap
20 | from .utils import utm_to_spherical
21 |
22 | CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
23 |
24 |
25 | def margin_option(side: str) -> Callable:
26 | return click.option(
27 | f"--margin-{side}",
28 | type=int,
29 | default=DEFAULT_MARGIN,
30 | metavar="MILLIMETERS",
31 | help=f"{side.title()} margin.",
32 | )
33 |
34 |
35 | def common_parameters(func: Callable) -> Callable:
36 | """Decorator to add common parameters (arguments and options) to a click command.
37 |
38 | Adapted from: https://github.com/pallets/click/issues/108#issuecomment-280489786
39 | """
40 |
41 | @click.argument("file", type=click.Path(dir_okay=False, path_type=Path))
42 | @click.option(
43 | "--tile-server",
44 | type=click.Choice(TILE_SERVERS),
45 | default=DEFAULT_TILE_SERVER,
46 | help="Tile server to serve as the base of the paper map.",
47 | )
48 | @click.option(
49 | "--api-key",
50 | type=str,
51 | metavar="KEY",
52 | help="API key for the chosen tile server (if applicable).",
53 | )
54 | @click.option(
55 | "--size",
56 | type=click.Choice(SIZES),
57 | default=DEFAULT_SIZE,
58 | help="Size of the paper map.",
59 | )
60 | @click.option(
61 | "--landscape",
62 | "use_landscape",
63 | default=False,
64 | is_flag=True,
65 | help="Use landscape orientation.",
66 | )
67 | @margin_option("top")
68 | @margin_option("right")
69 | @margin_option("bottom")
70 | @margin_option("left")
71 | @click.option(
72 | "--scale",
73 | type=int,
74 | default=DEFAULT_SCALE,
75 | help="Scale of the paper map.",
76 | )
77 | @click.option(
78 | "--dpi",
79 | type=int,
80 | default=DEFAULT_DPI,
81 | help="Dots per inch.",
82 | )
83 | @click.option(
84 | "--grid",
85 | "add_grid",
86 | default=False,
87 | is_flag=True,
88 | help="Add a coordinate grid overlay to the paper map.",
89 | )
90 | @click.option(
91 | "--grid-size",
92 | type=int,
93 | default=DEFAULT_GRID_SIZE,
94 | metavar="METERS",
95 | help="Size of the grid squares (if applicable).",
96 | )
97 | @wraps(func)
98 | def wrapper(*args, **kwargs):
99 | return func(*args, **kwargs)
100 |
101 | return wrapper
102 |
103 |
104 | @click.group(
105 | cls=DefaultGroup,
106 | default="latlon",
107 | default_if_no_args=True,
108 | context_settings=CONTEXT_SETTINGS,
109 | )
110 | @click.version_option(__version__, "-v", "--version")
111 | def cli():
112 | """PaperMap is a Python package and CLI for creating ready-to-print paper maps.
113 |
114 | Documentation: https://papermap.readthedocs.io/en/stable/
115 | """
116 |
117 |
118 | @cli.command()
119 | @click.argument("lat", type=float, metavar="LATITUDE")
120 | @click.argument("lon", type=float, metavar="LONGITUDE")
121 | @common_parameters
122 | def latlon(
123 | lat: float,
124 | lon: float,
125 | file: Path,
126 | tile_server: str = DEFAULT_TILE_SERVER,
127 | api_key: Optional[str] = None,
128 | size: str = DEFAULT_SIZE,
129 | use_landscape: bool = False,
130 | margin_top: int = DEFAULT_MARGIN,
131 | margin_right: int = DEFAULT_MARGIN,
132 | margin_bottom: int = DEFAULT_MARGIN,
133 | margin_left: int = DEFAULT_MARGIN,
134 | scale: int = DEFAULT_SCALE,
135 | dpi: int = DEFAULT_DPI,
136 | add_grid: bool = False,
137 | grid_size: int = DEFAULT_GRID_SIZE,
138 | ):
139 | """Generates a paper map for the given spherical coordinate (i.e. lat, lon) and outputs it to file."""
140 | # initialize PaperMap object
141 | pm = PaperMap(
142 | lat=lat,
143 | lon=lon,
144 | tile_server=tile_server,
145 | api_key=api_key,
146 | size=size,
147 | use_landscape=use_landscape,
148 | margin_top=margin_top,
149 | margin_right=margin_right,
150 | margin_bottom=margin_bottom,
151 | margin_left=margin_left,
152 | scale=scale,
153 | dpi=dpi,
154 | add_grid=add_grid,
155 | grid_size=grid_size,
156 | )
157 |
158 | # render it
159 | pm.render()
160 |
161 | # save it
162 | pm.save(file)
163 |
164 |
165 | @cli.command()
166 | @click.argument("easting", type=float, metavar="EASTING")
167 | @click.argument("northing", type=float, metavar="NORTHING")
168 | @click.argument("zone", type=int, metavar="ZONE-NUMBER")
169 | @click.argument("hemisphere", type=str, metavar="HEMISPHERE")
170 | @common_parameters
171 | def utm(
172 | easting: float,
173 | northing: float,
174 | zone: int,
175 | hemisphere: str,
176 | file: Path,
177 | tile_server: str = DEFAULT_TILE_SERVER,
178 | api_key: Optional[str] = None,
179 | size: str = DEFAULT_SIZE,
180 | use_landscape: bool = False,
181 | margin_top: int = DEFAULT_MARGIN,
182 | margin_right: int = DEFAULT_MARGIN,
183 | margin_bottom: int = DEFAULT_MARGIN,
184 | margin_left: int = DEFAULT_MARGIN,
185 | scale: int = DEFAULT_SCALE,
186 | dpi: int = DEFAULT_DPI,
187 | add_grid: bool = False,
188 | grid_size: int = DEFAULT_GRID_SIZE,
189 | ):
190 | """Generates a paper map for the given UTM coordinate and outputs it to file."""
191 | # convert UTM coordinate to spherical (i.e. lat, lon)
192 | lat, lon = utm_to_spherical(easting, northing, zone, hemisphere)
193 |
194 | # pass to `latlon` command
195 | latlon(
196 | lat=lat,
197 | lon=lon,
198 | file=file,
199 | tile_server=tile_server,
200 | api_key=api_key,
201 | size=size,
202 | use_landscape=use_landscape,
203 | margin_top=margin_top,
204 | margin_right=margin_right,
205 | margin_bottom=margin_bottom,
206 | margin_left=margin_left,
207 | scale=scale,
208 | dpi=dpi,
209 | add_grid=add_grid,
210 | grid_size=grid_size,
211 | )
212 |
--------------------------------------------------------------------------------
/src/papermap/constants.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from . import __version__
4 |
5 | NAME: str = "PaperMap"
6 |
7 | # headers used for requests
8 | HEADERS: Dict[str, str] = {
9 | "User-Agent": f"{NAME}v{__version__}",
10 | "Accept": "image/png,image/*;q=0.9,*/*;q=0.8",
11 | }
12 |
13 | # size (width / height) of map tiles
14 | TILE_SIZE: int = 256
15 |
16 | # properties of the WGS 84 datum
17 | WGS84_ELLIPSOID = (6_378_137, 1 / 298.257223563) # equatorial radius, flattening
18 | R: float = WGS84_ELLIPSOID[0]
19 | C: int = 40_075_017 # equatorial circumference
20 |
21 | FALSE_EASTING = 500_000
22 | FALSE_NORTHING = 10_000_000
23 |
--------------------------------------------------------------------------------
/src/papermap/defaults.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Tuple
2 |
3 | from .tile_server import TileServer
4 |
5 | SIZE_TO_DIMENSIONS_MAP: Dict[str, Tuple[int, int]] = dict(
6 | [
7 | ("a0", (841, 1189)),
8 | ("a1", (594, 841)),
9 | ("a2", (420, 594)),
10 | ("a3", (297, 420)),
11 | ("a4", (210, 297)),
12 | ("a5", (148, 210)),
13 | ("a6", (105, 148)),
14 | ("a7", (74, 105)),
15 | ("letter", (216, 279)),
16 | ("legal", (216, 356)),
17 | ]
18 | )
19 | SIZES = tuple(SIZE_TO_DIMENSIONS_MAP.keys())
20 | DEFAULT_SIZE: str = "a4"
21 |
22 | TILE_SERVERS_MAP: Dict[str, TileServer] = dict(
23 | [
24 | (
25 | "OpenStreetMap",
26 | TileServer(
27 | attribution="Map data: © OpenStreetMap contributors",
28 | url_template="http://{mirror}.tile.osm.org/{zoom}/{x}/{y}.png",
29 | mirrors=["a", "b", "c"],
30 | zoom_min=0,
31 | zoom_max=19,
32 | ),
33 | ),
34 | (
35 | "OpenStreetMap Monochrome",
36 | TileServer(
37 | attribution="Map data: © OpenStreetMap contributors",
38 | url_template="https://tiles.wmflabs.org/bw-mapnik/{zoom}/{x}/{y}.png",
39 | zoom_min=0,
40 | zoom_max=19,
41 | ),
42 | ),
43 | (
44 | "OpenTopoMap",
45 | TileServer(
46 | attribution="Map data: © OpenStreetMap contributors, SRTM. Map style: © OpenTopoMap (CC-BY-SA)",
47 | url_template="https://{mirror}.tile.opentopomap.org/{zoom}/{x}/{y}.png",
48 | mirrors=["a", "b", "c"],
49 | zoom_min=0,
50 | zoom_max=17,
51 | ),
52 | ),
53 | (
54 | "Thunderforest Landscape",
55 | TileServer(
56 | attribution="Map data: © OpenStreetMap contributors",
57 | url_template="https://{mirror}.tile.thunderforest.com/landscape/{zoom}/{x}/{y}.png?apikey={api_key}",
58 | mirrors=["a", "b", "c"],
59 | zoom_min=0,
60 | zoom_max=22,
61 | ),
62 | ),
63 | (
64 | "Thunderforest Outdoors",
65 | TileServer(
66 | attribution="Map data: © OpenStreetMap contributors",
67 | url_template="https://{mirror}.tile.thunderforest.com/outdoors/{zoom}/{x}/{y}.png?apikey={api_key}",
68 | mirrors=["a", "b", "c"],
69 | zoom_min=0,
70 | zoom_max=22,
71 | ),
72 | ),
73 | (
74 | "Thunderforest Transport",
75 | TileServer(
76 | attribution="Map data: © OpenStreetMap contributors",
77 | url_template="https://{mirror}.tile.thunderforest.com/transport/{zoom}/{x}/{y}.png?apikey={api_key}",
78 | mirrors=["a", "b", "c"],
79 | zoom_min=0,
80 | zoom_max=22,
81 | ),
82 | ),
83 | (
84 | "Thunderforest OpenCycleMap",
85 | TileServer(
86 | attribution="Map data: © OpenStreetMap contributors",
87 | url_template="https://{mirror}.tile.thunderforest.com/cycle/{zoom}/{x}/{y}.png?apikey={api_key}",
88 | mirrors=["a", "b", "c"],
89 | zoom_min=0,
90 | zoom_max=22,
91 | ),
92 | ),
93 | (
94 | "ESRI Standard",
95 | TileServer(
96 | attribution="Map data: © Esri",
97 | url_template="https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{zoom}/{y}/{x}.png",
98 | zoom_min=0,
99 | zoom_max=17,
100 | ),
101 | ),
102 | (
103 | "ESRI Satellite",
104 | TileServer(
105 | attribution="Map data: © Esri",
106 | url_template="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{zoom}/{y}/{x}.png",
107 | zoom_min=0,
108 | zoom_max=17,
109 | ),
110 | ),
111 | (
112 | "ESRI Topo",
113 | TileServer(
114 | attribution="Map data: © Esri",
115 | url_template="https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{zoom}/{y}/{x}.png",
116 | zoom_min=0,
117 | zoom_max=20,
118 | ),
119 | ),
120 | (
121 | "ESRI Dark Gray",
122 | TileServer(
123 | attribution="Map data: © Esri",
124 | url_template="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{zoom}/{y}/{x}.png",
125 | zoom_min=0,
126 | zoom_max=16,
127 | ),
128 | ),
129 | (
130 | "ESRI Light Gray",
131 | TileServer(
132 | attribution="Map data: © Esri",
133 | url_template="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{zoom}/{y}/{x}.png",
134 | zoom_min=0,
135 | zoom_max=16,
136 | ),
137 | ),
138 | (
139 | "ESRI Transportation",
140 | TileServer(
141 | attribution="Map data: © Esri",
142 | url_template="https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{zoom}/{y}/{x}.png",
143 | zoom_min=0,
144 | zoom_max=20,
145 | ),
146 | ),
147 | (
148 | "Geofabrik Topo",
149 | TileServer(
150 | attribution="Map data: © OpenStreetMap contributors",
151 | url_template="http://{mirror}.tile.geofabrik.de/15173cf79060ee4a66573954f6017ab0/{zoom}/{x}/{y}.png",
152 | mirrors=["a", "b", "c"],
153 | zoom_min=0,
154 | zoom_max=19,
155 | ),
156 | ),
157 | (
158 | "Google Maps",
159 | TileServer(
160 | attribution="Map data: © Google",
161 | url_template="http://mt{mirror}.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={zoom}",
162 | mirrors=[0, 1, 2, 3],
163 | zoom_min=0,
164 | zoom_max=19,
165 | ),
166 | ),
167 | (
168 | "Google Maps Satellite",
169 | TileServer(
170 | attribution="Map data: © Google",
171 | url_template="http://mt{mirror}.google.com/vt/lyrs=s&hl=en&x={x}&y={y}&z={zoom}",
172 | mirrors=[0, 1, 2, 3],
173 | zoom_min=0,
174 | zoom_max=19,
175 | ),
176 | ),
177 | (
178 | "Google Maps Satellite Hybrid",
179 | TileServer(
180 | attribution="Map data: © Google",
181 | url_template="http://mt{mirror}.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={zoom}",
182 | mirrors=[0, 1, 2, 3],
183 | zoom_min=0,
184 | zoom_max=19,
185 | ),
186 | ),
187 | (
188 | "Google Maps Terrain",
189 | TileServer(
190 | attribution="Map data: © Google",
191 | url_template="http://mt{mirror}.google.com/vt/lyrs=t&hl=en&x={x}&y={y}&z={zoom}",
192 | mirrors=[0, 1, 2, 3],
193 | zoom_min=0,
194 | zoom_max=19,
195 | ),
196 | ),
197 | (
198 | "Google Maps Terrain Hybrid",
199 | TileServer(
200 | attribution="Map data: © Google",
201 | url_template="http://mt{mirror}.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={zoom}",
202 | mirrors=[0, 1, 2, 3],
203 | zoom_min=0,
204 | zoom_max=19,
205 | ),
206 | ),
207 | (
208 | "HERE Terrain",
209 | TileServer(
210 | attribution="Map data: © HERE",
211 | url_template="https://{mirror}.aerial.maps.ls.hereapi.com/maptile/2.1/maptile/newest/terrain.day/{zoom}/{x}/{y}/256/png8?apiKey={api_key}",
212 | mirrors=[1, 2, 3, 4],
213 | zoom_min=0,
214 | zoom_max=20,
215 | ),
216 | ),
217 | (
218 | "HERE Satellite",
219 | TileServer(
220 | attribution="Map data: © HERE",
221 | url_template="https://{mirror}.aerial.maps.ls.hereapi.com/maptile/2.1/maptile/newest/satellite.day/{zoom}/{x}/{y}/256/png8?apiKey={api_key}",
222 | mirrors=[1, 2, 3, 4],
223 | zoom_min=0,
224 | zoom_max=20,
225 | ),
226 | ),
227 | (
228 | "HERE Hybrid",
229 | TileServer(
230 | attribution="Map data: © HERE",
231 | url_template="https://{mirror}.aerial.maps.ls.hereapi.com/maptile/2.1/maptile/newest/hybrid.day/{zoom}/{x}/{y}/256/png8?apiKey={api_key}",
232 | mirrors=[1, 2, 3, 4],
233 | zoom_min=0,
234 | zoom_max=20,
235 | ),
236 | ),
237 | (
238 | "Mapy.cz",
239 | TileServer(
240 | attribution="Map data: © OpenStreetMap contributors. Map style: © Sesznam.cz",
241 | url_template="https://m{mirror}.mapserver.mapy.cz/turist-m/{zoom}-{x}-{y}.png",
242 | mirrors=[1, 2, 3, 4],
243 | zoom_min=0,
244 | zoom_max=19,
245 | ),
246 | ),
247 | (
248 | "Stamen Terrain",
249 | TileServer(
250 | attribution="Map data: © OpenStreetMap contributors. Map style: © Stamen Design (CC-BY-3.0)",
251 | url_template="http://{mirror}.tile.stamen.com/terrain/{zoom}/{x}/{y}.png",
252 | mirrors=["a", "b", "c"],
253 | zoom_min=0,
254 | zoom_max=18,
255 | ),
256 | ),
257 | (
258 | "Stamen Toner",
259 | TileServer(
260 | attribution="Map data: © OpenStreetMap contributors. Map style: © Stamen Design (CC-BY-3.0)",
261 | url_template="http://{mirror}.tile.stamen.com/toner/{zoom}/{x}/{y}.png",
262 | mirrors=["a", "b", "c"],
263 | zoom_min=0,
264 | zoom_max=18,
265 | ),
266 | ),
267 | (
268 | "Stamen Toner Lite",
269 | TileServer(
270 | attribution="Map data: © OpenStreetMap contributors. Map style: © Stamen Design (CC-BY-3.0)",
271 | url_template="http://{mirror}.tile.stamen.com/toner-lite/{zoom}/{x}/{y}.png",
272 | mirrors=["a", "b", "c"],
273 | zoom_min=0,
274 | zoom_max=18,
275 | ),
276 | ),
277 | (
278 | "Komoot",
279 | TileServer(
280 | attribution="Map data: © OpenStreetMap contributors",
281 | url_template="http://{mirror}.tile.komoot.de/komoot-2/{zoom}/{x}/{y}.png",
282 | mirrors=["a", "b", "c"],
283 | zoom_min=0,
284 | zoom_max=19,
285 | ),
286 | ),
287 | (
288 | "Wikimedia",
289 | TileServer(
290 | attribution="Map data: © OpenStreetMap contributors",
291 | url_template="https://maps.wikimedia.org/osm-intl/{zoom}/{x}/{y}.png",
292 | zoom_min=0,
293 | zoom_max=19,
294 | ),
295 | ),
296 | (
297 | "Hike & Bike",
298 | TileServer(
299 | attribution="Map data: © OpenStreetMap contributors",
300 | url_template="http://{mirror}.tiles.wmflabs.org/hikebike/{zoom}/{x}/{y}.png",
301 | mirrors=["a", "b", "c"],
302 | zoom_min=0,
303 | zoom_max=20,
304 | ),
305 | ),
306 | (
307 | "AllTrails",
308 | TileServer(
309 | attribution="Map data: © OpenStreetMap contributors",
310 | url_template="http://alltrails.com/tiles/alltrailsOutdoors/{zoom}/{x}/{y}.png",
311 | zoom_min=0,
312 | zoom_max=20,
313 | ),
314 | ),
315 | ]
316 | )
317 | TILE_SERVERS = tuple(TILE_SERVERS_MAP.keys())
318 | DEFAULT_TILE_SERVER: str = "OpenStreetMap"
319 |
320 | DEFAULT_SCALE: int = 25_000
321 | DEFAULT_MARGIN: int = 10
322 | DEFAULT_DPI: int = 300
323 | DEFAULT_BACKGROUND_COLOR: str = "#fff"
324 | DEFAULT_GRID_SIZE: int = 1_000
325 | DEFAULT_NUM_RETRIES: int = 3
326 |
--------------------------------------------------------------------------------
/src/papermap/papermap.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | from concurrent.futures import ThreadPoolExecutor
4 | from decimal import Decimal
5 | from io import BytesIO
6 | from itertools import count
7 | from math import ceil, floor, radians
8 | from pathlib import Path
9 | from typing import List, Optional, Tuple, Union
10 |
11 | from fpdf import FPDF
12 | from PIL import Image
13 | from requests import Session
14 | from requests.exceptions import HTTPError
15 |
16 | from . import __version__
17 | from .constants import HEADERS, NAME, TILE_SIZE
18 | from .defaults import (
19 | DEFAULT_BACKGROUND_COLOR,
20 | DEFAULT_DPI,
21 | DEFAULT_GRID_SIZE,
22 | DEFAULT_MARGIN,
23 | DEFAULT_SCALE,
24 | DEFAULT_SIZE,
25 | DEFAULT_TILE_SERVER,
26 | SIZE_TO_DIMENSIONS_MAP,
27 | SIZES,
28 | TILE_SERVERS,
29 | TILE_SERVERS_MAP,
30 | )
31 | from .tile import Tile
32 | from .utils import (
33 | drange,
34 | get_string_formatting_arguments,
35 | lat_to_y,
36 | lon_to_x,
37 | mm_to_px,
38 | pt_to_mm,
39 | scale_to_zoom,
40 | spherical_to_utm,
41 | )
42 |
43 |
44 | class PaperMap:
45 | """A paper map.
46 |
47 | >>> from papermap import PaperMap
48 | >>> pm = PaperMap(13.75889, 100.49722)
49 | >>> pm.render()
50 | >>> pm.save("Bangkok.pdf")
51 |
52 | Args:
53 | lat: Latitude of the center of the map.
54 | lon: Longitude of the center of the map
55 | tile_server: Tile server to serve as the base of the paper map. Defaults to `OpenStreetMap`.
56 | api_key: API key for the chosen tile server (if applicable). Defaults to `None`.
57 | size: Size of the paper map. Defaults to `a4`.
58 | landscape: Use landscape orientation. Defaults to `False`.
59 | margin_top: Top margin (in mm). Defaults to `10`.
60 | margin_right: Right margin (in mm). Defaults to `10`.
61 | margin_bottom: Bottom margin (in mm). Defaults to `10`.
62 | margin_left: Left margin (in mm). Defaults to `10`.
63 | scale: Scale of the paper map. Defaults to `25000`.
64 | dpi: Dots per inch. Defaults to `300`.
65 | background_color: Background color of the paper map. Defaults to `#fff`.
66 | add_grid: Add a coordinate grid overlay to the paper map. Defaults to `False`.
67 | grid_size: Size of the grid squares (if applicable, in meters). Defaults to `1000`.
68 |
69 | Raises:
70 | ValueError: If the tile server is invalid.
71 | ValueError: If no API key is specified (when applicable).
72 | ValueError: If the paper size is invalid.
73 | ValueError: If the scale is "out of bounds".
74 | """
75 |
76 | def __init__(
77 | self,
78 | lat: float,
79 | lon: float,
80 | tile_server: str = DEFAULT_TILE_SERVER,
81 | api_key: Optional[str] = None,
82 | size: str = DEFAULT_SIZE,
83 | use_landscape: bool = False,
84 | margin_top: int = DEFAULT_MARGIN,
85 | margin_right: int = DEFAULT_MARGIN,
86 | margin_bottom: int = DEFAULT_MARGIN,
87 | margin_left: int = DEFAULT_MARGIN,
88 | scale: int = DEFAULT_SCALE,
89 | dpi: int = DEFAULT_DPI,
90 | background_color: str = DEFAULT_BACKGROUND_COLOR,
91 | add_grid: bool = False,
92 | grid_size: int = DEFAULT_GRID_SIZE,
93 | ) -> None:
94 | self.lat = lat
95 | self.lon = lon
96 | self.api_key = api_key
97 | self.use_landscape = use_landscape
98 | self.margin_top = margin_top
99 | self.margin_right = margin_right
100 | self.margin_bottom = margin_bottom
101 | self.margin_left = margin_left
102 | self.scale = scale
103 | self.dpi = dpi
104 | self.background_color = background_color
105 | self.add_grid = add_grid
106 | self.grid_size = grid_size
107 |
108 | # get the tile server
109 | if tile_server in TILE_SERVERS_MAP:
110 | self.tile_server = TILE_SERVERS_MAP[tile_server]
111 | else:
112 | raise ValueError(
113 | f"Invalid tile server. Please choose one of {', '.join(TILE_SERVERS)}"
114 | )
115 |
116 | # get the tile server mirrors
117 | self.mirrors = self.tile_server.mirrors if self.tile_server.mirrors else []
118 |
119 | # check whether an API key is provided, if it is needed
120 | if (
121 | "a" in get_string_formatting_arguments(self.tile_server.url_template)
122 | and self.api_key is None
123 | ):
124 | raise ValueError(f"No API key specified for {tile_server} tile server")
125 |
126 | # get the paper size (in mm)
127 | if size in SIZE_TO_DIMENSIONS_MAP:
128 | self.width, self.height = SIZE_TO_DIMENSIONS_MAP[size]
129 | if self.use_landscape:
130 | self.width, self.height = self.height, self.width
131 | else:
132 | raise ValueError(
133 | f"Invalid paper size. Please choose one of {', '.join(SIZES)}"
134 | )
135 |
136 | # compute the zoom and resize factor
137 | self.zoom = scale_to_zoom(self.scale, self.lat, self.dpi)
138 | self.zoom_scaled = floor(self.zoom)
139 | self.resize_factor = 2**self.zoom_scaled / 2**self.zoom
140 |
141 | # make sure the zoom is not out of bounds
142 | if (
143 | self.zoom_scaled < self.tile_server.zoom_min
144 | or self.zoom_scaled > self.tile_server.zoom_max
145 | ):
146 | raise ValueError(f"Scale out of bounds for {tile_server} tile server.")
147 |
148 | # compute the width and height of the image (in mm)
149 | self.image_width = self.width - self.margin_left - self.margin_right
150 | self.image_height = self.height - self.margin_top - self.margin_bottom
151 |
152 | # perform conversions
153 | self.image_width_px = mm_to_px(self.image_width, self.dpi)
154 | self.image_height_px = mm_to_px(self.image_height, self.dpi)
155 | self.φ = radians(self.lat)
156 | self.λ = radians(self.lon)
157 |
158 | # compute the scaled grid size (in mm)
159 | self.grid_size_scaled = Decimal(self.grid_size * 1_000 / self.scale)
160 |
161 | # compute the scaled width and height of the image (in px)
162 | self.image_width_scaled_px = round(self.image_width_px * self.resize_factor)
163 | self.image_height_scaled_px = round(self.image_height_px * self.resize_factor)
164 |
165 | # determine the center tile
166 | self.x_center = lon_to_x(self.lon, self.zoom_scaled)
167 | self.y_center = lat_to_y(self.lat, self.zoom_scaled)
168 |
169 | # determine the tiles required to produce the map image
170 | self.x_min = floor(
171 | self.x_center - (0.5 * self.image_width_scaled_px / TILE_SIZE)
172 | )
173 | self.y_min = floor(
174 | self.y_center - (0.5 * self.image_height_scaled_px / TILE_SIZE)
175 | )
176 | self.x_max = ceil(
177 | self.x_center + (0.5 * self.image_width_scaled_px / TILE_SIZE)
178 | )
179 | self.y_max = ceil(
180 | self.y_center + (0.5 * self.image_height_scaled_px / TILE_SIZE)
181 | )
182 |
183 | # initialize the tiles
184 | self.tiles = []
185 | for x in range(self.x_min, self.x_max):
186 | for y in range(self.y_min, self.y_max):
187 | # x and y may have crossed the date line
188 | max_tile = 2**self.zoom_scaled
189 | x_tile = (x + max_tile) % max_tile
190 | y_tile = (y + max_tile) % max_tile
191 |
192 | bbox = (
193 | round(
194 | (x_tile - self.x_center) * TILE_SIZE
195 | + self.image_width_scaled_px / 2
196 | ),
197 | round(
198 | (y_tile - self.y_center) * TILE_SIZE
199 | + self.image_height_scaled_px / 2
200 | ),
201 | round(
202 | (x_tile + 1 - self.x_center) * TILE_SIZE
203 | + self.image_width_scaled_px / 2
204 | ),
205 | round(
206 | (y_tile + 1 - self.y_center) * TILE_SIZE
207 | + self.image_height_scaled_px / 2
208 | ),
209 | )
210 |
211 | self.tiles.append(Tile(x_tile, y_tile, self.zoom_scaled, bbox))
212 |
213 | # initialize the pdf document
214 | self.pdf = FPDF(
215 | unit="mm",
216 | format=(self.width, self.height),
217 | )
218 | self.pdf.set_font("Helvetica")
219 | self.pdf.set_fill_color(255, 255, 255)
220 | self.pdf.set_top_margin(self.margin_top)
221 | self.pdf.set_auto_page_break(True, self.margin_bottom)
222 | self.pdf.set_left_margin(self.margin_left)
223 | self.pdf.set_right_margin(self.margin_right)
224 | self.pdf.add_page()
225 |
226 | def compute_grid_coordinates(
227 | self,
228 | ) -> Tuple[List[Tuple[Decimal, str]], List[Tuple[Decimal, str]]]:
229 | # convert WGS 84 point (lat, lon) into UTM coordinate (x, y, zone_number, hemisphere)
230 | x, y, _, _ = spherical_to_utm(self.lat, self.lon)
231 |
232 | # round UTM/RD coordinates to nearest thousand
233 | x_rnd = round(x, -3)
234 | y_rnd = round(y, -3)
235 |
236 | # compute distance between x/y and x/y_rnd in mm
237 | dx = Decimal((x - x_rnd) / self.scale * 1000)
238 | dy = Decimal((y - y_rnd) / self.scale * 1000)
239 |
240 | # determine center grid coordinate (in mm)
241 | x_grid_center = Decimal(self.image_width / 2) - dx
242 | y_grid_center = Decimal(self.image_height / 2) - dy
243 |
244 | # determine start grid coordinate (in mm)
245 | x_grid_start = x_grid_center % self.grid_size_scaled
246 | y_grid_start = y_grid_center % self.grid_size_scaled
247 |
248 | # determine the start grid coordinate label
249 | x_label_start = int(
250 | Decimal(x_rnd / 1000) - x_grid_center // self.grid_size_scaled
251 | )
252 | y_label_start = int(
253 | Decimal(y_rnd / 1000) + y_grid_center // self.grid_size_scaled
254 | )
255 |
256 | # determine the grid coordinates (in mm)
257 | x_grid_cs = list(
258 | drange(x_grid_start, Decimal(self.image_width), self.grid_size_scaled)
259 | )
260 | y_grid_cs = list(
261 | drange(y_grid_start, Decimal(self.image_height), self.grid_size_scaled)
262 | )
263 |
264 | # determine the grid coordinates labels
265 | x_labels = [x_label_start + i for i in range(len(x_grid_cs))]
266 | y_labels = [y_label_start - i for i in range(len(y_grid_cs))]
267 |
268 | x_grid_cs_and_labels = list(zip(x_grid_cs, map(str, x_labels)))
269 | y_grid_cs_and_labels = list(zip(y_grid_cs, map(str, y_labels)))
270 |
271 | return x_grid_cs_and_labels, y_grid_cs_and_labels
272 |
273 | def render_grid(self) -> None:
274 | if self.add_grid:
275 | self.pdf.set_draw_color(0, 0, 0)
276 | self.pdf.set_line_width(0.1)
277 | self.pdf.set_font_size(8)
278 |
279 | # get grid coordinates
280 | x_grid_cs_and_labels, y_grid_cs_and_labels = self.compute_grid_coordinates()
281 |
282 | # draw vertical grid lines
283 | for x, label in x_grid_cs_and_labels:
284 | x_ = float(x + self.margin_left)
285 | label_width = self.pdf.get_string_width(label)
286 |
287 | # draw grid line
288 | self.pdf.line(x_, self.margin_top, x_, self.margin_top + self.pdf.eph)
289 |
290 | # draw label
291 | if x_ + label_width < self.margin_left + self.pdf.epw:
292 | self.pdf.set_xy(x_ - label_width / 2, self.margin_top)
293 | self.pdf.cell(w=label_width, txt=label, align="C", fill=True)
294 |
295 | # draw horizontal grid lines
296 | for y, label in y_grid_cs_and_labels:
297 | y_ = float(y + self.margin_top)
298 | label_width = self.pdf.get_string_width(label)
299 | pt_to_mm(self.pdf.font_size)
300 |
301 | # draw grid line
302 | self.pdf.line(self.margin_left, y_, self.margin_left + self.pdf.epw, y_)
303 |
304 | # draw label
305 | if y_ + label_width < self.margin_top + self.pdf.eph:
306 | self.pdf.set_xy(self.margin_left, y_ + label_width / 2)
307 | with self.pdf.rotation(90):
308 | self.pdf.cell(w=label_width, txt=label, align="C", fill=True)
309 |
310 | self.pdf.set_font_size(12)
311 |
312 | def render_attribution_and_scale(self) -> None:
313 | text = f"{self.tile_server.attribution}. Created with {NAME}. Scale: 1:{self.scale}"
314 | self.pdf.set_xy(
315 | self.margin_left + self.pdf.epw - self.pdf.get_string_width(text),
316 | self.margin_top + self.pdf.eph - pt_to_mm(self.pdf.font_size_pt),
317 | )
318 | self.pdf.cell(w=0, txt=text, align="R", fill=True)
319 |
320 | def download_tiles(
321 | self, num_retries: int = 3, sleep_between_retries: Optional[int] = None
322 | ) -> None:
323 | # download the tile images
324 | for num_retry in count():
325 | # get the unsuccessful tiles
326 | tiles = [tile for tile in self.tiles if not tile.success]
327 |
328 | # break if all tiles successful
329 | if not tiles:
330 | break
331 |
332 | # possibly sleep between retries
333 | if num_retry > 0 and sleep_between_retries is not None:
334 | time.sleep(sleep_between_retries)
335 |
336 | # break if max number of retries exceeded
337 | if num_retry >= num_retries:
338 | raise RuntimeError(
339 | f"Could not download {len(tiles)}/{len(self.tiles)} tiles after {num_retries} retries."
340 | )
341 |
342 | with Session() as session:
343 | session.headers.update(HEADERS)
344 |
345 | with ThreadPoolExecutor(min(32, os.cpu_count() or 1 + 4)) as executor:
346 | responses = executor.map(
347 | session.get,
348 | [
349 | self.tile_server.format_url_template(
350 | tile=tile, api_key=self.api_key
351 | )
352 | for tile in tiles
353 | ],
354 | )
355 |
356 | for tile, r in zip(tiles, responses):
357 | try:
358 | r.raise_for_status()
359 | # set tile image
360 | tile.image = Image.open(BytesIO(r.content)).convert("RGBA")
361 | except HTTPError:
362 | pass
363 |
364 | def render_base_layer(self) -> None:
365 | # download all the required tiles
366 | self.download_tiles()
367 |
368 | # initialize scaled map image
369 | self.map_image_scaled = Image.new(
370 | "RGB",
371 | (self.image_width_scaled_px, self.image_height_scaled_px),
372 | self.background_color,
373 | )
374 |
375 | # paste all the tiles in the scaled map image
376 | for tile in self.tiles:
377 | if tile.image is not None:
378 | self.map_image_scaled.paste(tile.image, tile.bbox, tile.image)
379 |
380 | # resize the scaled map image
381 | self.map_image = self.map_image_scaled.resize(
382 | (self.image_width_px, self.image_height_px), Image.LANCZOS
383 | )
384 |
385 | def render(self) -> None:
386 | """Render the paper map, consisting of the map image, grid (if applicable), attribution and scale."""
387 | # render the base layer
388 | self.render_base_layer()
389 |
390 | # paste the map image onto the paper map
391 | self.pdf.image(self.map_image, w=self.image_width, h=self.image_height)
392 |
393 | # possibly render a coordinate grid
394 | self.render_grid()
395 |
396 | # render the attribution and scale to the map
397 | self.render_attribution_and_scale()
398 |
399 | def save(
400 | self, file: Union[str, Path], title: str = NAME, author: str = NAME
401 | ) -> None:
402 | """Save the paper map to a file.
403 |
404 | Args:
405 | file: The file to save the paper map to.
406 | title: The title of the PDF document. Defaults to `PaperMap`.
407 | author: The author of the PDF document. Defaults to `PaperMap`.
408 | """
409 | self.file = Path(file)
410 | self.pdf.set_title(title)
411 | self.pdf.set_author(author)
412 | self.pdf.set_creator(f"{NAME} v{__version__}")
413 | self.pdf.output(self.file)
414 |
415 | def __repr__(self) -> str:
416 | return f"PaperMap({self.lat}, {self.lon})"
417 |
--------------------------------------------------------------------------------
/src/papermap/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sgraaf/papermap/5bd250b5c1e2a6f329f01d985dcc8264947084c1/src/papermap/py.typed
--------------------------------------------------------------------------------
/src/papermap/tile.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Optional, Tuple
3 |
4 | from PIL.Image import Image
5 |
6 |
7 | @dataclass
8 | class Tile:
9 | """A tile from a tile server.
10 |
11 | Args:
12 | x: The x coordinate of the tile.
13 | y: The y coordinate of the tile.
14 | zoom: The zoom level of the tile.
15 | bbox: The bounding box of the tile.
16 | image: The image of the tile. Defaults to `None`.
17 | """
18 |
19 | x: int
20 | y: int
21 | zoom: int
22 | bbox: Tuple[int, int, int, int]
23 | image: Optional[Image] = None
24 |
25 | @property
26 | def success(self) -> bool:
27 | """Whether the tile was successfully downloaded or not."""
28 | return isinstance(self.image, Image)
29 |
30 | def format_url_template(
31 | self,
32 | url_template: str,
33 | mirror: Optional[str] = None,
34 | api_key: Optional[str] = None,
35 | ) -> str:
36 | """Format a URL template with the tile's coordinates and zoom level.
37 |
38 | Args:
39 | url_template: The URL template to format.
40 | mirror: The mirror to use. Defaults to `None`.
41 | api_key: The API key to use. Defaults to `None`.
42 |
43 | Returns:
44 | The formatted URL template.
45 | """
46 | return url_template.format(
47 | mirror=mirror,
48 | x=self.x,
49 | y=self.y,
50 | zoom=self.zoom,
51 | api_key=api_key,
52 | )
53 |
--------------------------------------------------------------------------------
/src/papermap/tile_server.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from itertools import cycle
3 | from typing import List, Optional, Union
4 |
5 | from .tile import Tile
6 |
7 |
8 | @dataclass
9 | class TileServer:
10 | """A tile server.
11 |
12 | Args:
13 | attribution: The attribution of the tile server.
14 | url_template: The URL template of the tile server. Allowed placeholders
15 | are `{x}`, `{y}`, `{zoom}`, `{mirror}` and `{api_key}`, where `{x}`
16 | refers to the x coordinate of the tile, `{y}` refers to the y
17 | coordinate of the tile, `{zoom}` to the zoom level, `{mirror}` to
18 | the mirror (optional) and `{api_key}` to the API key (optional). See
19 | ``_
20 | for more information.
21 | zoom_min: The minimum zoom level of the tile server.
22 | zoom_max: The maximum zoom level of the tile server.
23 | mirrors: The mirrors of the tile server. Defaults to `None`.
24 | """
25 |
26 | attribution: str
27 | url_template: str
28 | zoom_min: int
29 | zoom_max: int
30 | mirrors: Optional[List[Optional[Union[str, int]]]] = None
31 | mirrors_cycle: cycle = field(init=False)
32 |
33 | def __post_init__(self) -> None:
34 | self.mirrors_cycle = cycle(self.mirrors or [None])
35 |
36 | def format_url_template(self, tile: Tile, api_key: Optional[str] = None) -> str:
37 | """Format the URL template with the tile's coordinates and zoom level.
38 |
39 | Args:
40 | tile: The tile to format the URL template with.
41 | api_key: The API key to use. Defaults to `None`.
42 |
43 | Returns:
44 | The formatted URL template.
45 | """
46 | return self.url_template.format(
47 | mirror=next(self.mirrors_cycle),
48 | x=tile.x,
49 | y=tile.y,
50 | zoom=tile.zoom,
51 | api_key=api_key,
52 | )
53 |
--------------------------------------------------------------------------------
/src/papermap/typing.py:
--------------------------------------------------------------------------------
1 | """Type information used throughout `papermap`."""
2 | from typing import Tuple, Union
3 |
4 | Degree = float
5 | """Angle in degrees."""
6 |
7 | Radian = float
8 | """Angle in radians."""
9 |
10 | Angle = Union[Degree, Radian]
11 | """Angle in either degrees or radians."""
12 |
13 | Pixel = int
14 | """Number of pixels."""
15 |
16 | DMS = Tuple[int, int, float]
17 | """Degrees, Minutes, and Seconds (DMS)."""
18 |
19 | Cartesian_2D = Tuple[float, float]
20 | """Two-dimensional Cartesian (x, y) coordinates."""
21 |
22 | Cartesian_3D = Tuple[float, float, float]
23 | """Thee-dimensional Cartesian (x, y, z) coordinates."""
24 |
25 | Spherical_2D = Tuple[Angle, Angle]
26 | """Two-dimensional Spherical (lat, lon) coordinates."""
27 |
28 | Spherical_3D = Tuple[Angle, Angle, Angle]
29 | """Thee-dimensional Spherical (lat, lon, height) coordinates."""
30 |
31 | UTM_Coordinate = Tuple[float, float, int, str]
32 | """UTM coordinate (easting, northing, zone, hemisphere)."""
33 |
--------------------------------------------------------------------------------
/src/papermap/utils.py:
--------------------------------------------------------------------------------
1 | from decimal import Decimal
2 | from math import (
3 | asinh,
4 | atan,
5 | atan2,
6 | atanh,
7 | cos,
8 | cosh,
9 | degrees,
10 | hypot,
11 | log,
12 | radians,
13 | sin,
14 | sinh,
15 | sqrt,
16 | tan,
17 | )
18 | from math import pi as π
19 | from string import Formatter
20 | from typing import Dict, Iterator, List, Union
21 |
22 | from .constants import FALSE_EASTING, FALSE_NORTHING, TILE_SIZE, WGS84_ELLIPSOID, C, R
23 | from .defaults import DEFAULT_DPI
24 | from .typing import (
25 | DMS,
26 | Angle,
27 | Cartesian_3D,
28 | Degree,
29 | Pixel,
30 | Spherical_2D,
31 | UTM_Coordinate,
32 | )
33 |
34 |
35 | def clip(val: float, lower: float, upper: float) -> float:
36 | """Clips a value to [lower, upper] range.
37 |
38 | Args:
39 | val: The value.
40 | lower: The lower bound.
41 | upper: The upper bound.
42 |
43 | Returns:
44 | The value clipped to [lower, upper] range.
45 | """
46 | return min(max(val, lower), upper)
47 |
48 |
49 | def wrap(angle: Angle, limit: Angle) -> Angle:
50 | """Wraps an angle to [-limit, limit] range.
51 |
52 | Args:
53 | angle: The angle.
54 | limit: The lower and upper limit.
55 |
56 | Returns:
57 | The angle wrapped to [-limit, limit] range.
58 | """
59 | if -limit <= angle <= limit: # angle already in [-limit, limit] range
60 | return angle
61 | # angle %= limit
62 | return (angle + limit) % (2 * limit) - limit
63 |
64 |
65 | def wrap90(angle: Degree) -> Degree:
66 | """Wraps an angle to [-90, 90] range.
67 |
68 | Args:
69 | angle: The angle.
70 |
71 | Returns:
72 | The angle wrapped to [-90, 90] range.
73 | """
74 | return wrap(angle, 90)
75 |
76 |
77 | def wrap180(angle: Degree) -> Degree:
78 | """Wraps an angle to [-180, 180] range
79 |
80 | Args:
81 | angle: The angle.
82 |
83 | Returns:
84 | The angle wrapped to [-180, 180] range.
85 | """
86 | return wrap(angle, 180)
87 |
88 |
89 | def wrap360(angle: Degree) -> Degree:
90 | """Wraps an angle to [0, 360) range
91 |
92 | Args:
93 | angle: The angle.
94 |
95 | Returns:
96 | The angle wrapped to [0, 360) range.
97 | """
98 | if 0 <= angle < 360: # angle already in [0, 360) range
99 | return angle
100 | return angle % 360
101 |
102 |
103 | def lon_to_x(lon: Degree, zoom: int) -> float:
104 | """Converts longitude to x (tile coordinate), given a zoom level.
105 |
106 | Args:
107 | lon: The longitude.
108 | zoom: The zoom level.
109 |
110 | Returns:
111 | The x (tile coordinate).
112 | """
113 | # constrain lon to [-180, 180] range
114 | lon = wrap180(lon)
115 |
116 | # convert lon to [0, 1] range
117 | x = ((lon + 180.0) / 360) * 2**zoom
118 |
119 | return x
120 |
121 |
122 | def x_to_lon(x: Union[int, float], zoom: int) -> Degree:
123 | """Converts x (tile coordinate) to longitude, given a zoom level.
124 |
125 | Args:
126 | x: The tile coordinate.
127 | zoom: The zoom level.
128 |
129 | Returns:
130 | The longitude.
131 | """
132 | lon = x / (2**zoom) * 360 - 180
133 | return lon
134 |
135 |
136 | def lat_to_y(lat: Degree, zoom: int) -> float:
137 | """Converts latitude to y (tile coordinate), given a zoom level.
138 |
139 | Args:
140 | lat: The latitude.
141 | zoom: The zoom level.
142 |
143 | Returns:
144 | The y (tile coordinate).
145 | """
146 | # constrain lat to [-90, 90] range
147 | lat = wrap90(lat)
148 |
149 | # convert lat to radians
150 | φ = radians(lat)
151 |
152 | # convert lat to [0, 1] range
153 | y = ((1 - log(tan(φ) + 1 / cos(φ)) / π) / 2) * 2**zoom
154 |
155 | return y
156 |
157 |
158 | def y_to_lat(y: Union[int, float], zoom: int) -> Degree:
159 | """Converts y (tile coordinate) to latitude, given a zoom level.
160 |
161 | Args:
162 | y: The tile coordinate.
163 | zoom: The zoom level.
164 |
165 | Returns:
166 | The latitude.
167 | """
168 | lat = atan(sinh(π * (1 - 2 * y / (2**zoom)))) / π * 180
169 | return lat
170 |
171 |
172 | def x_to_px(x: int, x_center: int, width: Pixel, tile_size: Pixel = TILE_SIZE) -> Pixel:
173 | """Convert x (tile coordinate) to pixels.
174 |
175 | Args:
176 | x: The tile coordinate.
177 | x_center: Tile coordinate of the center tile.
178 | width: The image width.
179 | tile_size: The tile size. Defaults to `256`.
180 |
181 | Returns:
182 | The pixels.
183 | """
184 | return round(width / 2 - (x_center - x) * tile_size)
185 |
186 |
187 | def y_to_px(
188 | y: int, y_center: int, height: Pixel, tile_size: Pixel = TILE_SIZE
189 | ) -> Pixel:
190 | """Convert y (tile coordinate) to pixel
191 |
192 | Args:
193 | y: The tile coordinate.
194 | y_center: Tile coordinate of the center tile.
195 | height: The image height.
196 | tile_size: The tile size. Defaults to `256`.
197 |
198 | Returns:
199 | The pixels.
200 | """
201 | return round(height / 2 - (y_center - y) * tile_size)
202 |
203 |
204 | def mm_to_px(mm: float, dpi: int = DEFAULT_DPI) -> Pixel:
205 | """Convert millimeters to pixels, given the dpi.
206 |
207 | Args:
208 | mm: The millimeters.
209 | dpi: Dots per inch. Defaults to `300`.
210 |
211 | Returns:
212 | The pixels.
213 | """
214 | return round(mm * dpi / 25.4)
215 |
216 |
217 | def px_to_mm(px: int, dpi: int = DEFAULT_DPI) -> float:
218 | """Convert pixels to millimeters, given the dpi.
219 |
220 | Args:
221 | px: The pixels.
222 | dpi: Dots per inch. Defaults to `300`.
223 |
224 | Returns:
225 | The millimeters.
226 | """
227 | return px * 25.4 / dpi
228 |
229 |
230 | def mm_to_pt(mm: float) -> float:
231 | """Convert millimeters to points.
232 |
233 | Args:
234 | mm: The millimeters.
235 |
236 | Returns:
237 | The points.
238 | """
239 | return mm * 72 / 25.4
240 |
241 |
242 | def pt_to_mm(pt: float) -> float:
243 | """Convert points to millimeters.
244 |
245 | Args:
246 | pt: The points.
247 |
248 | Returns:
249 | The millimeters.
250 | """
251 | return pt * 25.4 / 72
252 |
253 |
254 | def dd_to_dms(dd: Degree) -> DMS:
255 | """Convert Decimal Degrees (DD) to Degrees, Minutes, and Seconds (DMS).
256 |
257 | Args:
258 | dd: The Decimal Degrees.
259 |
260 | Returns:
261 | The Degrees, Minutes, and Seconds.
262 | """
263 | is_positive = dd >= 0
264 | dd = abs(dd)
265 | m, s = divmod(dd * 3600, 60)
266 | d, m = divmod(m, 60)
267 | d = d if is_positive else -d
268 | return round(d), round(m), round(s, 6)
269 |
270 |
271 | def dms_to_dd(dms: DMS) -> Degree:
272 | """Convert Degrees, Minutes, and Seconds (DMS) to Decimal Degrees (DD).
273 |
274 | Args:
275 | dms: The Degrees, Minutes, and Seconds.
276 |
277 | Returns:
278 | The Decimal Degrees.
279 | """
280 | d, m, s = dms
281 | is_positive = d >= 0
282 | d = d if is_positive else -d
283 | return round((d + m / 60 + s / 3600) * (1 if is_positive else -1), 6)
284 |
285 |
286 | def spherical_to_cartesian(lat: Degree, lon: Degree, r: float = R) -> Cartesian_3D:
287 | """Convert spherical coordinates (i.e. lat, lon) to cartesian coordinates (i.e. x, y, z).
288 |
289 | Adapted from: ``_
290 |
291 | Args:
292 | lat: The latitude.
293 | lon: The longitude.
294 | r: The radius of the sphere. Defaults to `6378137`
295 | (equatorial Earth radius).
296 |
297 | Returns:
298 | The cartesian coordinates (x, y, z).
299 | """
300 | # convert lat and lon (in deg) to radians
301 | φ = radians(lat)
302 | λ = radians(lon)
303 |
304 | x = r * cos(φ) * cos(λ)
305 | y = r * cos(φ) * sin(λ)
306 | z = r * sin(φ)
307 |
308 | return x, y, z
309 |
310 |
311 | def cartesian_to_spherical(x: float, y: float, z: float) -> Spherical_2D:
312 | """Convert cartesian coordinates (i.e. x, y, z) to spherical coordinates (i.e. lat, lon).
313 |
314 | Adapted from: ``_
315 |
316 | Args:
317 | x: The x coordinate.
318 | y: The y coordinate.
319 | z: The z coordinate.
320 |
321 | Returns:
322 | The spherical coordinates (i.e. lat, lon).
323 | """
324 | # compute the spherical coordinates in radians
325 | φ = atan2(z, hypot(x, y))
326 | λ = atan2(y, x)
327 |
328 | # convert radians to degrees
329 | lat = degrees(φ)
330 | lon = degrees(λ)
331 |
332 | return lat, lon
333 |
334 |
335 | def scale_to_zoom(scale: int, lat: Degree, dpi: int = DEFAULT_DPI) -> float:
336 | """Compute the zoom level, given the latitude, scale and dpi.
337 |
338 | Args:
339 | scale: The scale.
340 | lat: The latitude.
341 | dpi: Dots per inch. Defaults to `300`.
342 |
343 | Returns:
344 | The zoom level.
345 | """
346 | # convert lat to radians
347 | φ = radians(lat)
348 |
349 | # compute the zoom level
350 | scale_px = scale * 25.4 / (1000 * dpi)
351 | zoom = log(C * cos(φ) / scale_px, 2) - 8
352 | return zoom
353 |
354 |
355 | def zoom_to_scale(zoom: int, lat: Degree, dpi: int = DEFAULT_DPI) -> float:
356 | """Compute the scale, given the latitude, zoom level and dpi
357 |
358 | Args:
359 | zoom: The zoom level.
360 | lat: The latitude.
361 | dpi: Dots per inch. Defaults to `300`.
362 |
363 | Returns:
364 | The scale.
365 | """
366 | # convert lat to radians
367 | φ = radians(lat)
368 |
369 | # compute the scale
370 | scale_px = C * cos(φ) / 2 ** (zoom + 8)
371 | scale = scale_px * dpi * 1000 / 25.4
372 | return scale
373 |
374 |
375 | def spherical_to_zone(lat: Degree, lon: Degree) -> int:
376 | """Compute the UTM zone number of a given spherical coordinate (i.e. lat, lon).
377 |
378 | Args:
379 | lat: The latitude.
380 | lon: The longitude.
381 |
382 | Returns:
383 | The UTM zone number.
384 | """
385 | if 56 <= lat < 64 and 3 <= lon < 12:
386 | return 32
387 |
388 | if 72 <= lat <= 84 and lon >= 0:
389 | if lon < 9:
390 | return 31
391 | elif lon < 21:
392 | return 33
393 | elif lon < 33:
394 | return 35
395 | elif lon < 42:
396 | return 37
397 |
398 | return int((lon + 180) / 6) + 1
399 |
400 |
401 | def compute_central_lon(zone: int) -> Degree:
402 | """Compute the central longitude of a given UTM zone number.
403 |
404 | Args:
405 | zone: The UTM zone number.
406 |
407 | Returns:
408 | The central longitude.
409 | """
410 | return (zone - 1) * 6 - 180 + 3
411 |
412 |
413 | def spherical_to_utm(lat: Degree, lon: Degree) -> UTM_Coordinate:
414 | """Convert a spherical coordinate (i.e. lat, lon) to a UTM coordinate.
415 |
416 | Based on formulas from (Karney, 2011).
417 |
418 | Adapted from: ``_
419 |
420 | Args:
421 | lat: The latitude.
422 | lon: The longitude.
423 |
424 | References:
425 | `Karney, C. F. (2011). Transverse Mercator with an accuracy of a few nanometers. Journal of Geodesy, 85(8), 475-485. `_
426 | """
427 | # constrain lat to [-90.0, 90.0] range, lon to [-180.0, 180.0] range
428 | lat = wrap90(lat)
429 | lon = wrap180(lon)
430 |
431 | # ensure lat is not out of range for conversion
432 | if not -80.0 <= lat <= 84.0:
433 | raise ValueError(
434 | f"Latitude out of range [-80.0, 84.0] for UTM conversion: {lat}"
435 | )
436 |
437 | # get the zone number and letter
438 | zone = spherical_to_zone(lat, lon)
439 |
440 | # compute the lon of the central meridian
441 | λ0 = radians(compute_central_lon(zone))
442 |
443 | # convert lat and lon to radians
444 | φ = radians(lat)
445 | λ = radians(lon) - λ0
446 |
447 | # compute some quantities used throughout the equations below
448 | a, f = WGS84_ELLIPSOID
449 | e = sqrt(f * (2 - f)) # eccentricity
450 | n = f / (2 - f) # third flattening
451 | n2 = n**2
452 | n3 = n**3
453 | n4 = n**4
454 | n5 = n**5
455 | n6 = n**6
456 | k0 = 0.9996 # scale factor on central meridian
457 | λ_cos = cos(λ)
458 | λ_sin = sin(λ)
459 |
460 | # (Karney, 2011, Eqs. (7-9))
461 | τ = tan(φ)
462 | σ = sinh(e * atanh(e * τ / sqrt(1 + τ**2)))
463 | τʹ = τ * sqrt(1 + σ**2) - σ * sqrt(1 + τ**2)
464 |
465 | # (Karney, 2011, Eq. (10))
466 | ξʹ = atan2(τʹ, λ_cos)
467 | ηʹ = asinh(λ_sin / sqrt(τʹ**2 + λ_cos**2))
468 |
469 | # (Karney, 2011, Eq. (35))
470 | α = [
471 | 1,
472 | n / 2
473 | - 2 * n2 / 3
474 | + 5 * n3 / 16
475 | + 41 * n4 / 180
476 | - 127 * n5 / 288
477 | + 7891 * n6 / 37800,
478 | 13 * n2 / 48
479 | - 3 * n3 / 5
480 | + 557 * n4 / 1440
481 | + 281 * n5 / 630
482 | - 1983433 * n6 / 1935360,
483 | 61 * n3 / 240 - 103 * n4 / 140 + 15061 * n5 / 26880 + 167603 * n6 / 181440,
484 | 49561 * n4 / 161280 - 179 * n5 / 168 + 6601661 * n6 / 7257600,
485 | 34729 * n5 / 80640 - 3418889 * n6 / 1995840,
486 | 212378941 * n6 / 319334400,
487 | ]
488 |
489 | # (Karney, 2011, Eq. (11))
490 | ξ = ξʹ
491 | for j in range(1, 7):
492 | ξ += α[j] * sin(2 * j * ξʹ) * cosh(2 * j * ηʹ)
493 | η = ηʹ
494 | for j in range(1, 7):
495 | η += α[j] * cos(2 * j * ξʹ) * sinh(2 * j * ηʹ)
496 |
497 | # 2πA is the circumference of a meridian
498 | # (Karney, 2011, Eq. (14))
499 | A = a / (1 + n) * (1 + n2 / 4 + n4 / 64 + n6 / 256)
500 |
501 | # compute the x (easting) and y (northing)
502 | # (Karney, 2011, Eq. (13))
503 | x = k0 * A * η
504 | y = k0 * A * ξ
505 |
506 | # shift easting and northing to false origins
507 | x += FALSE_EASTING
508 | if y < 0:
509 | y += FALSE_NORTHING
510 |
511 | # get the hemisphere
512 | hemisphere = "N" if lat >= 0 else "S"
513 |
514 | return x, y, zone, hemisphere
515 |
516 |
517 | def utm_to_spherical(x: float, y: float, zone: int, hemisphere: str) -> Spherical_2D:
518 | """Convert a UTM coordinate to a spherical coordinate (i.e. lat, lon).
519 |
520 | Based on formulas from (Karney, 2011).
521 |
522 | Adapted from: ``_
523 |
524 | Args:
525 | x: The easting.
526 | y: The northing.
527 | z: The zone number.
528 | l: The hemisphere.
529 |
530 | References:
531 | `Karney, C. F. (2011). Transverse Mercator with an accuracy of a few nanometers. Journal of Geodesy, 85(8), 475-485. `_
532 | """
533 | # shift easting and northing from false origins
534 | x -= FALSE_EASTING
535 | if hemisphere == "S":
536 | y -= FALSE_NORTHING
537 |
538 | # compute some quantities used throughout the equations below
539 | a, f = WGS84_ELLIPSOID
540 | e = sqrt(f * (2 - f)) # eccentricity
541 | n = f / (2 - f) # third flattening
542 | n2 = n**2
543 | n3 = n**3
544 | n4 = n**4
545 | n5 = n**5
546 | n6 = n**6
547 | k0 = 0.9996 # scale factor on central meridian
548 |
549 | # 2πA is the circumference of a meridian
550 | # (Karney, 2011, Eq. (14))
551 | A = a / (1 + n) * (1 + n2 / 4 + n4 / 64 + n6 / 256)
552 |
553 | # (Karney, 2011, Eq. (15))
554 | ξ = y / (k0 * A)
555 | η = x / (k0 * A)
556 |
557 | # (Karney, 2011, Eq. (36))
558 | β = [
559 | 1,
560 | n / 2
561 | - 2 * n2 / 3
562 | + 37 * n3 / 96
563 | - n4 / 360
564 | - 81 * n5 / 512
565 | + 96199 * n6 / 604800,
566 | n2 / 48 + n3 / 15 - 437 * n4 / 1440 + 46 * n5 / 105 - 1118711 * n6 / 3870720,
567 | 17 * n3 / 480 - 37 * n4 / 840 - 209 * n5 / 4480 + 5569 * n6 / 90720,
568 | 4397 * n4 / 161280 - 11 * n5 / 504 - 830251 * n6 / 7257600,
569 | 4583 * n5 / 161280 - 108847 * n6 / 3991680,
570 | 20648693 * n6 / 638668800,
571 | ]
572 |
573 | # (Karney, 2011, Eq. (11))
574 | ξʹ = ξ
575 | for j in range(1, 7):
576 | ξʹ -= β[j] * sin(2 * j * ξ) * cosh(2 * j * η)
577 | ηʹ = η
578 | for j in range(1, 7):
579 | ηʹ -= β[j] * cos(2 * j * ξ) * sinh(2 * j * η)
580 |
581 | ηʹ_sinh = sinh(ηʹ)
582 | ξʹ_cos = cos(ξʹ)
583 |
584 | # (Karney, 2011, Eq. (18))
585 | τʹ = sin(ξʹ) / sqrt(ηʹ_sinh**2 + ξʹ_cos**2)
586 | λ = atan2(ηʹ_sinh, ξʹ_cos)
587 |
588 | # (Karney, 2011, Eqs. (19-21))
589 | δτi = 1.0
590 | τi = τʹ
591 | while abs(δτi) > 1e-12:
592 | σi = sinh(e * atanh(e * τi / sqrt(1 + τi**2)))
593 | τiʹ = τi * sqrt(1 + σi**2) - σi * sqrt(1 + τi**2)
594 | δτi = (
595 | (τʹ - τiʹ)
596 | / sqrt(1 + τiʹ**2)
597 | * (1 + (1 - e**2) * τi**2)
598 | / ((1 - e**2) * sqrt(1 + τi**2))
599 | )
600 | τi += δτi
601 | τ = τi
602 |
603 | # (Karney, 2011, Eq. (22))
604 | φ = atan(τ)
605 |
606 | # convert to degrees
607 | lat = degrees(φ)
608 | lon = degrees(λ)
609 |
610 | return lat, lon
611 |
612 |
613 | def get_string_formatting_arguments(s: str) -> List[str]:
614 | return [t[1] for t in Formatter().parse(s) if t[1] is not None]
615 |
616 |
617 | def is_out_of_bounds(test: Dict[str, float], bounds: Dict[str, float]) -> bool:
618 | if test["lat_min"] < bounds["lat_min"]:
619 | return True
620 | elif test["lon_min"] < bounds["lon_min"]:
621 | return True
622 | elif test["lat_max"] > bounds["lat_max"]:
623 | return True
624 | elif test["lon_max"] > bounds["lat_max"]:
625 | return True
626 | return False
627 |
628 |
629 | def drange(start: Decimal, stop: Decimal, step: Decimal) -> Iterator[Decimal]:
630 | while start < stop:
631 | yield start
632 | start += step
633 |
--------------------------------------------------------------------------------