diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..aa982ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,46 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,py}] +charset = utf-8 + +# 4 space indentation +[*.py] +max_line_length = 120 +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[mkdocs.yml] +indent_size = 2 + +# Tab indentation (no size specified) +[Makefile] +max_line_length = 150 +indent_style = tab +trim_trailing_whitespace = false + +# Indentation override for all JS under lib directory +[lib/**.js] +indent_style = space +indent_size = 2 + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 6deafc2..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4367230..fdbf928 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,9 +13,9 @@ jobs: steps: - name: Git config run: git config --global core.autocrlf input - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python v${{matrix.python-version}} - ${{runner.os}} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{matrix.python-version}} cache: pip @@ -42,14 +42,17 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: [3.7] + python-version: [3.11] needs: "lint_and_test" + if: github.ref == 'refs/heads/main' steps: + - name: Install cairo + run: sudo apt-get install libcairo2-dev - name: Git config run: git config --global core.autocrlf input - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python v${{matrix.python-version}} - ${{runner.os}} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{matrix.python-version}} cache: pip @@ -57,8 +60,12 @@ jobs: run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.txt + pip install -r requirements-cairo.txt pip install -r requirements-examples.txt - name: Build examples + env: + API_KEY_JAWG: ${{ secrets.API_KEY_JAWG }} + API_KEY_STADIA: ${{ secrets.API_KEY_STADIA }} run: | cd examples mkdir build @@ -74,7 +81,50 @@ jobs: (ls *cairo*.png && mv *cairo*.png build/.) || echo "no cairo png files found!" cd - - name: Archive examples - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: build_examples path: examples/build + deploy: + runs-on: ${{matrix.os}} + strategy: + matrix: + os: [ubuntu-latest] + python-version: [3.11] + needs: "build" + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup git and push to remote + run: | + export GIT_USER=${{ github.actor }} + git config --global core.autocrlf input + - name: Git clone + run: git clone https://github.com/lowtower/py-staticmaps --branch assets assets + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: build_examples + - name: Move pictures and commit + run: | + mv *.png assets/. + mv *.svg assets/. + cd assets + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git fetch + git pull + git add *.png + git add *.svg + git commit -m "Automatic update of example image files `date +\"%d-%m-%Y %T\"`" + git status + pwd + ls -lrt + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + directory: assets + branch: assets diff --git a/Makefile b/Makefile index 635f546..78f1a1f 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +1,87 @@ +PROJECT=staticmaps +SRC_CORE=staticmaps +SRC_TEST=tests +SRC_EXAMPLES=examples +SRC_COMPLETE=$(SRC_CORE) $(SRC_TEST) $(SRC_EXAMPLES) docs/gen_ref_pages.py +PYTHON=python3 + +help: ## Print help for each target + $(info Makefile low-level Python API.) + $(info =============================) + $(info ) + $(info Available commands:) + $(info ) + @grep '^[[:alnum:]_-]*:.* ##' $(MAKEFILE_LIST) \ + | sort | awk 'BEGIN {FS=":.* ## "}; {printf "%-25s %s\n", $$1, $$2};' + +clean: ## Cleanup + @rm -rf ./.env + @rm -f ./*.pyc + @rm -rf ./__pycache__ + @rm -f $(SRC_CORE)/*.pyc + @rm -rf $(SRC_CORE)/__pycache__ + @rm -f $(SRC_TEST)/*.pyc + @rm -rf $(SRC_TEST)/__pycache__ + @rm -f $(SRC_EXAMPLES)/*.pyc + @rm -rf $(SRC_EXAMPLES)/__pycache__ + @rm -rf $(SRC_EXAMPLES)/build + @rm -rf ./.coverage + @rm -rf ./coverage.xml + @rm -rf ./.pytest_cache + @rm -rf ./.mypy_cache + @rm -rf ./site + @rm -rf ./reports + .PHONY: setup -setup: - python3 -m venv .env - .env/bin/pip install --upgrade pip +setup: ## Setup virtual environment + $(PYTHON) -m venv .env + .env/bin/pip install --upgrade pip wheel .env/bin/pip install --upgrade --requirement requirements.txt .env/bin/pip install --upgrade --requirement requirements-dev.txt .env/bin/pip install --upgrade --requirement requirements-examples.txt .PHONY: install -install: setup +install: setup ## install package .env/bin/pip install . .PHONY: lint -lint: - .env/bin/pylint \ - setup.py staticmaps examples tests +lint: ## Lint the code + .env/bin/pycodestyle \ + --max-line-length=120 \ + setup.py $(SRC_COMPLETE) + .env/bin/isort \ + setup.py $(SRC_COMPLETE) \ + --check --diff + .env/bin/black \ + --line-length 120 \ + --check \ + --diff \ + setup.py $(SRC_COMPLETE) + .env/bin/pyflakes \ + setup.py $(SRC_COMPLETE) .env/bin/flake8 \ - setup.py staticmaps examples tests + setup.py $(SRC_COMPLETE) + .env/bin/pylint \ + setup.py $(SRC_COMPLETE) .env/bin/mypy \ - setup.py staticmaps examples tests - .env/bin/black \ - --line-length 120 \ - --check \ - --diff \ - setup.py staticmaps examples tests + setup.py $(SRC_COMPLETE) + .env/bin/codespell \ + README.md staticmaps/*.py tests/*.py examples/*.py .PHONY: format -format: +format: ## Format the code + .env/bin/isort \ + setup.py $(SRC_COMPLETE) + .env/bin/autopep8 \ + -i -r \ + setup.py $(SRC_COMPLETE) .env/bin/black \ - --line-length 120 \ - setup.py staticmaps examples tests + --line-length 120 \ + setup.py $(SRC_COMPLETE) .PHONY: run-examples -run-examples: +run-examples: ## Generate example images + (cd examples && rm -r build) (cd examples && PYTHONPATH=.. ../.env/bin/python custom_objects.py) (cd examples && PYTHONPATH=.. ../.env/bin/python draw_gpx.py running.gpx) (cd examples && PYTHONPATH=.. ../.env/bin/python frankfurt_newyork.py) @@ -40,24 +90,35 @@ run-examples: (cd examples && PYTHONPATH=.. ../.env/bin/python tile_providers.py) (cd examples && PYTHONPATH=.. ../.env/bin/python us_capitals.py) (cd examples && PYTHONPATH=.. ../.env/bin/python idl.py) - (cd examples && mv *.svg build/.) - (cd examples && mv *pillow*png build/.) - (cd examples && mv *cairo*png build/.) - (cd -) + (cd examples && mkdir -p build) + (cd examples && ls *.svg 2>/dev/null && mv *.svg build/.) || echo "no svg files found!" + (cd examples && ls *pillow*.png 2>/dev/null && mv *pillow*.png build/.) || echo "no pillow png files found!" + (cd examples && ls *cairo*.png 2>/dev/null && mv *cairo*.png build/.) || echo "no cairo png files found!" + .PHONY: test -test: +test: ## Test the code PYTHONPATH=. .env/bin/python -m pytest tests +.PHONY: coverage +coverage: ## Generate coverage report for the code + PYTHONPATH=. .env/bin/python -m pytest --cov=staticmaps --cov-branch --cov-report=term --cov-report=html tests + .PHONY: build-package -build-package: +build-package: ## Build the package rm -rf dist PYTHONPATH=. .env/bin/python setup.py sdist PYTHONPATH=. .env/bin/twine check dist/* .PHONY: upload-package-test -upload-package-test: +upload-package-test: ## Upload package test PYTHONPATH=. .env/bin/twine upload --repository-url https://test.pypi.org/legacy/ dist/* .PHONY: upload-package -upload-package: +upload-package: ## Upload package PYTHONPATH=. .env/bin/twine upload --repository py-staticmaps dist/* + +.PHONY: documentation +documentation: ## Generate documentation + @if type mkdocs >/dev/null 2>&1 ; then .env/bin/python -m mkdocs build --clean --verbose ; \ + else echo "SKIPPED. Run '.env/bin/python -m install mkdocs' first." >&2 ; fi + diff --git a/README.md b/README.md index a1d1912..b5f878a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![CI](https://github.com/flopp/py-staticmaps/workflows/CI/badge.svg)](https://github.com/flopp/py-staticmaps/actions?query=workflow%3ACI) +[![CI](https://github.com/lowtower/py-staticmaps/workflows/CI/badge.svg)](https://github.com/lowtower/py-staticmaps/actions?query=workflow%3ACI) [![PyPI Package](https://img.shields.io/pypi/v/py-staticmaps.svg)](https://pypi.org/project/py-staticmaps/) [![Format](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](LICENSE) @@ -17,6 +17,10 @@ A python module to create static map images (PNG, SVG) with markers, geodesic li - Non-anti-aliased drawing via `PILLOW` - Anti-aliased drawing via `pycairo` (optional; only if `pycairo` is installed properly) - SVG creation via `svgwrite` +- optional tightening of the map to object or custom boundaries + - SVG only + - tiles are being "cropped" to given boundaries + - might lead to reduction of image quality ## Installation @@ -60,16 +64,47 @@ image = context.render_pillow(800, 500) image.save("frankfurt_newyork.pillow.png") # render anti-aliased png (this only works if pycairo is installed) -image = context.render_cairo(800, 500) -image.write_to_png("frankfurt_newyork.cairo.png") +if staticmaps.cairo_is_supported(): + image = context.render_cairo(800, 500) + image.write_to_png("frankfurt_newyork.cairo.png") # render svg svg_image = context.render_svg(800, 500) with open("frankfurt_newyork.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("frankfurt_newyork.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) + +context2 = staticmaps.Context() +context2.set_tile_provider(staticmaps.tile_provider_StamenToner) +context2.add_object(staticmaps.Bounds([frankfurt, newyork])) + +# render svg +svg_image = context2.render_svg(800, 500) +with open("frankfurt_newyork.bounds.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context2.set_tighten_to_bounds(True) +svg_image = context2.render_svg(800, 500) +with open("frankfurt_newyork.bounds.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) ``` -![franfurt_newyork](../assets/frankfurt_newyork.png?raw=true) +#### Cairo example +![frankfurt_newyork](../assets/frankfurt_newyork.cairo.png?raw=true) +#### SVG example +![frankfurt_newyork_svg](../assets/frankfurt_newyork.svg?raw=true) +#### SVG tight example +![frankfurt_newyork_svg_tight](../assets/frankfurt_newyork.tight.svg?raw=true) +#### SVG custom bounds example +![frankfurt_newyork_bounds_svg](../assets/frankfurt_newyork.bounds.svg?raw=true) +#### SVG custom bounds tight example +![frankfurt_newyork_bounds_svg_tight](../assets/frankfurt_newyork.bounds.tight.svg?raw=true) ### Transparent Polygons @@ -109,8 +144,10 @@ svg_image = context.render_svg(800, 500) with open("freiburg_area.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) ``` - -![draw_gpx](../assets/freiburg_area.png?raw=true) +#### Cairo example +![freiburg_area](../assets/freiburg_area.cairo.png?raw=true) +#### SVG tight example +![freiburg_area_svg_tight](../assets/freiburg_area.tight.svg?raw=true) ### Drawing a GPX Track + Image Marker (PNG) @@ -138,16 +175,30 @@ for p in gpx.walk(only_points=True): context.add_object(marker) break -# render non-anti-aliased png +# render png via pillow image = context.render_pillow(800, 500) image.save("draw_gpx.pillow.png") -# render anti-aliased png (this only works if pycairo is installed) -image = context.render_cairo(800, 500) -image.write_to_png("draw_gpx.cairo.png") -``` +# render png via cairo +if staticmaps.cairo_is_supported(): + image = context.render_cairo(800, 500) + image.write_to_png("draw_gpx.cairo.png") -![draw_gpx](../assets/draw_gpx.png?raw=true) +# render svg +svg_image = context.render_svg(800, 500) +with open("draw_gpx.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("draw_gpx.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) +``` +#### Cairo example +![draw_gpx](../assets/running.cairo.png?raw=true) +#### SVG tight example +![draw_gpx_svg_tight](../assets/running.tight.svg?raw=true) ### US State Capitals @@ -177,8 +228,10 @@ image.save("us_capitals.pillow.png") image = context.render_cairo(800, 500) image.write_to_png("us_capitals.cairo.png") ``` - -![us_capitals](../assets/us_capitals.png?raw=true) +#### Cairo example +![us_capitals](../assets/us_capitals.cairo.png?raw=true) +#### SVG tight example +![us_capitals_svg_tight](../assets/us_capitals.tight.svg?raw=true) ### Geodesic Circles @@ -206,7 +259,14 @@ image = context.render_cairo(800, 600) image.write_to_png("geodesic_circles.cairo.png") ``` -![geodesic_circles](../assets/geodesic_circles.png?raw=true) +#### Cairo example +![geodesic_circles](../assets/geodesic_circles.cairo.png?raw=true) +#### Pillow example +![geodesic_circles_pillow](../assets/geodesic_circles.pillow.png?raw=true) +#### SVG example +![geodesic_circles_svg](../assets/geodesic_circles.svg?raw=true) +#### SVG tight example +![geodesic_circles_svg_tight](../assets/geodesic_circles.tight.svg?raw=true) ### Other Examples diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..37f489d --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,2 @@ +* [Start](index.md) +* [Code Reference](reference/) diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py new file mode 100644 index 0000000..222be2f --- /dev/null +++ b/docs/gen_ref_pages.py @@ -0,0 +1,46 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +TOP_LEVEL_NAME = "staticmaps" +DIRECTORY = "reference" +SRC = "staticmaps" + + +def main() -> None: + """ + main entry point + """ + + nav = mkdocs_gen_files.Nav() + + for path in sorted(Path(SRC).rglob("*.py")): + module_path = path.relative_to(SRC).with_suffix("") + + doc_path = path.relative_to(SRC).with_suffix(".md") + full_doc_path = Path(DIRECTORY, doc_path) + + parts = list(module_path.parts) + # omit __init__, __main__, cli.py + if parts[-1] in ["__init__", "__main__", "cli"]: + continue + + if not parts: + continue + + nav[tuple(parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as file_handle: + ident = ".".join(parts) + file_handle.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + # mkdocs_gen_files.set_edit_path(full_doc_path, Path("../") / path) + + with mkdocs_gen_files.open(f"{DIRECTORY}/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) + + +main() diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..536d380 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +# py-staticmaps + +A python module to create static map images (PNG, SVG) with markers, geodesic lines, etc. diff --git a/examples/custom_objects.py b/examples/custom_objects.py index 9c04098..916ecfc 100644 --- a/examples/custom_objects.py +++ b/examples/custom_objects.py @@ -1,17 +1,23 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example Custom Objects""" # Copyright (c) 2021 Florian Pigorsch; see /LICENSE for licensing information try: import cairo # type: ignore except ImportError: pass + import s2sphere # type: ignore + import staticmaps class TextLabel(staticmaps.Object): + """ + TextLabel Custom object, inherits from staticmaps.Object + """ + def __init__(self, latlng: s2sphere.LatLng, text: str) -> None: staticmaps.Object.__init__(self) self._latlng = latlng @@ -21,25 +27,44 @@ def __init__(self, latlng: s2sphere.LatLng, text: str) -> None: self._font_size = 12 def latlng(self) -> s2sphere.LatLng: + """Return latlng of object + + Returns: + s2sphere.LatLng: latlng of object + """ return self._latlng def bounds(self) -> s2sphere.LatLngRect: + """Return bounds of object + + Returns: + s2sphere.LatLngRect: bounds of object + """ return s2sphere.LatLngRect.from_point(self._latlng) def extra_pixel_bounds(self) -> staticmaps.PixelBoundsT: + """Return extra pixel bounds from object + + Returns: + PixelBoundsT: extra pixel bounds + """ # Guess text extents. tw = len(self._text) * self._font_size * 0.5 th = self._font_size * 1.2 - w = max(self._arrow, tw + 2.0 * self._margin) - return (int(w / 2.0), int(th + 2.0 * self._margin + self._arrow), int(w / 2), 0) + w = max(self._arrow, int(tw + 2.0 * self._margin)) + return int(w / 2.0), int(th + 2.0 * self._margin + self._arrow), int(w / 2), 0 def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: + """Render object using PILLOW + + Parameters: + renderer (PillowRenderer): pillow renderer + """ x, y = renderer.transformer().ll2pixel(self.latlng()) - x = x + renderer.offset_x() + x += renderer.offset_x() - left, top, right, bottom = renderer.draw().textbbox((0, 0), self._text) - th = bottom - top - tw = right - left + textbox = renderer.draw().textbbox((0, 0, 0, 0), self._text) + tw, th = (textbox[2] - textbox[0], textbox[3] - textbox[1]) w = max(self._arrow, tw + 2 * self._margin) h = th + 2 * self._margin @@ -58,6 +83,11 @@ def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: renderer.draw().text((x - tw / 2, y - self._arrow - h / 2 - th / 2), self._text, fill=(0, 0, 0, 255)) def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: + """Render object using cairo + + Parameters: + renderer (CairoRenderer): cairo renderer + """ x, y = renderer.transformer().ll2pixel(self.latlng()) ctx = renderer.context() @@ -101,13 +131,18 @@ def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: ctx.stroke() def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: + """Render object using svgwrite + + Parameters: + renderer (SvgRenderer): svg renderer + """ x, y = renderer.transformer().ll2pixel(self.latlng()) # guess text extents tw = len(self._text) * self._font_size * 0.5 th = self._font_size * 1.2 - w = max(self._arrow, tw + 2 * self._margin) + w = max(self._arrow, int(tw + 2 * self._margin)) h = th + 2 * self._margin path = renderer.drawing().path( @@ -164,3 +199,9 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: svg_image = context.render_svg(800, 500) with open("custom_objects.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("custom_objects.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/draw_gpx.py b/examples/draw_gpx.py index 85a64ab..21a5abe 100644 --- a/examples/draw_gpx.py +++ b/examples/draw_gpx.py @@ -1,11 +1,12 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example Draw GPX""" # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import sys import gpxpy # type: ignore + import staticmaps context = staticmaps.Context() @@ -39,3 +40,9 @@ svg_image = context.render_svg(800, 500) with open("running.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("running.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/frankfurt_newyork.py b/examples/frankfurt_newyork.py index f9e41ad..9d375e4 100644 --- a/examples/frankfurt_newyork.py +++ b/examples/frankfurt_newyork.py @@ -1,15 +1,20 @@ #!/usr/bin/env python -# py-staticmaps -# Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information +"""py-staticmaps - Example Frankfurt-New York""" +# Copyright (c) 2020-2025 Florian Pigorsch & Contributors. All rights reserved. +# +# Use of this source code is governed by a MIT-style +# license that can be found in the LICENSE file. import staticmaps context = staticmaps.Context() context.set_tile_provider(staticmaps.tile_provider_ArcGISWorldImagery) +warsaw = staticmaps.create_latlng(52.233207, 21.061419) frankfurt = staticmaps.create_latlng(50.110644, 8.682092) newyork = staticmaps.create_latlng(40.712728, -74.006015) +los_angeles = staticmaps.create_latlng(33.999099, -118.411735) context.add_object(staticmaps.Line([frankfurt, newyork], color=staticmaps.BLUE, width=4)) context.add_object(staticmaps.Marker(frankfurt, color=staticmaps.GREEN, size=12)) @@ -19,7 +24,7 @@ image = context.render_pillow(800, 500) image.save("frankfurt_newyork.pillow.png") -# render png via cairo +# render anti-aliased png (this only works if pycairo is installed) if staticmaps.cairo_is_supported(): cairo_image = context.render_cairo(800, 500) cairo_image.write_to_png("frankfurt_newyork.cairo.png") @@ -28,3 +33,37 @@ svg_image = context.render_svg(800, 500) with open("frankfurt_newyork.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render png via pillow - tight boundaries +context.set_tighten_to_bounds(True) +image = context.render_pillow(800, 500) +image.save("frankfurt_newyork.tight.pillow.png") + +# render png via cairo - tight boundaries +if staticmaps.cairo_is_supported(): + context.set_tighten_to_bounds(True) + cairo_image = context.render_cairo(800, 500) + cairo_image.write_to_png("frankfurt_newyork.tight.cairo.png") + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("frankfurt_newyork.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) + +context2 = staticmaps.Context() +context2.set_tile_provider(staticmaps.tile_provider_CartoDarkNoLabels) +context2.add_object(staticmaps.Marker(frankfurt, color=staticmaps.GREEN, size=12)) +context2.add_object(staticmaps.Marker(newyork, color=staticmaps.RED, size=12)) +context2.add_object(staticmaps.Bounds([warsaw, los_angeles])) + +# render svg +svg_image = context2.render_svg(800, 500) +with open("frankfurt_newyork.warsaw_los_angeles_bounds.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context2.set_tighten_to_bounds(True) +svg_image = context2.render_svg(800, 500) +with open("frankfurt_newyork.warsaw_los_angeles_bounds.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/freiburg_area.py b/examples/freiburg_area.py index e2e1a64..b8b74a6 100644 --- a/examples/freiburg_area.py +++ b/examples/freiburg_area.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example Freiburg Area""" # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import staticmaps @@ -457,3 +457,9 @@ svg_image = context.render_svg(800, 500) with open("freiburg_area.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("freiburg_area.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/geodesic_circles.py b/examples/geodesic_circles.py index 51e63bc..c795b35 100644 --- a/examples/geodesic_circles.py +++ b/examples/geodesic_circles.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example Geodesic Circles""" # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import staticmaps @@ -13,7 +13,7 @@ context.add_object(staticmaps.Circle(center1, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.RED, width=2)) context.add_object(staticmaps.Circle(center2, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.GREEN, width=2)) -context.add_object(staticmaps.Marker(center1, color=staticmaps.RED)) +context.add_object(staticmaps.Marker(center1)) context.add_object(staticmaps.Marker(center2, color=staticmaps.GREEN)) # render png via pillow @@ -29,3 +29,9 @@ svg_image = context.render_svg(800, 600) with open("geodesic_circles.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("geodesic_circles.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/tile_providers.py b/examples/tile_providers.py index ea443b0..7553777 100644 --- a/examples/tile_providers.py +++ b/examples/tile_providers.py @@ -1,8 +1,10 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example Tile Providers""" # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information +import os + import staticmaps context = staticmaps.Context() @@ -17,6 +19,17 @@ context.add_object(staticmaps.Marker(p3, color=staticmaps.YELLOW)) for name, provider in staticmaps.default_tile_providers.items(): + # Jawg and Stadia require access tokens + if "jawg" in provider.name(): + if "API_KEY_JAWG" in os.environ: + provider.set_api_key(os.environ.get("API_KEY_JAWG")) # type: ignore + else: + continue + if "stadia" in provider.name(): + if "API_KEY_STADIA" in os.environ: + provider.set_api_key(os.environ.get("API_KEY_STADIA")) # type: ignore + else: + continue context.set_tile_provider(provider) # render png via pillow @@ -29,6 +42,13 @@ cairo_image.write_to_png(f"provider_{name}.cairo.png") # render svg + context.set_tighten_to_bounds() svg_image = context.render_svg(800, 500) with open(f"provider_{name}.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + + # render svg - tight boundaries + context.set_tighten_to_bounds(True) + svg_image = context.render_svg(800, 500) + with open(f"provider_{name}.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/examples/us_capitals.py b/examples/us_capitals.py index 9e16ecb..d9014de 100644 --- a/examples/us_capitals.py +++ b/examples/us_capitals.py @@ -1,10 +1,12 @@ #!/usr/bin/env python -# py-staticmaps +"""py-staticmaps - Example US capitals""" # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import json + import requests + import staticmaps context = staticmaps.Context() @@ -32,3 +34,9 @@ svg_image = context.render_svg(800, 500) with open("us_capitals.svg", "w", encoding="utf-8") as f: svg_image.write(f, pretty=True) + +# render svg - tight boundaries +context.set_tighten_to_bounds(True) +svg_image = context.render_svg(800, 500) +with open("us_capitals.tight.svg", "w", encoding="utf-8") as f: + svg_image.write(f, pretty=True) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..6e43780 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: py-staticmaps +site_description: "A python module to create static map images (PNG, SVG) with markers, geodesic lines, etc." + +repo_url: https://github.com/flopp/py-staticmaps +repo_name: py-staticmaps + +use_directory_urls: false + +theme: + icon: + repo: fontawesome/brands/github + name: "material" + palette: + - media: "(prefers-color-scheme: light)" + primary: "blue" + accent: "grey" + scheme: default + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + primary: "blue" + accent: "white" + scheme: slate + toggle: + icon: material/toggle-switch + name: Switch to light mode + +plugins: + - search + - autorefs + - section-index + - gen-files: + scripts: + - docs/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [staticmaps] + options: + show_root_heading: true + show_source: true + watch: + - staticmaps + +markdown_extensions: + - pymdownx.highlight + - pymdownx.magiclink + - pymdownx.superfences diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index f66bccb..0000000 --- a/mypy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[mypy] -check_untyped_defs = True -disallow_incomplete_defs = True -disallow_untyped_defs = True \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 16db555..67f32d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,32 @@ +# Runtime requirements +--requirement requirements.txt + +# Linting/Tooling +autopep8 black +codespell flake8 +isort mypy +pur +pycodestyle +pyflakes pylint + +# Testing +coverage pytest +pytest-cov + +# Building twine + +# Documentation +mkdocs +mkdocstrings +mkdocstrings-python +mkdocs-gen-files +mkdocs-literate-nav +mkdocs-material +mkdocs-section-index +Pygments diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..037160d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,88 @@ +[tool:pytest] +addopts = -v -ra -s +pythonpath = src +testpaths = tests +markers = + debug: debugging tests + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + if self\.debug + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + +[coverage:paths] +source = ./staticmaps/* + +[coverage:html] +directory = reports + +[coverage:run] +branch = True +parallel = True +omit = + staticmaps/__init__.py + +[isort] +profile = black + +known_third_party = + numpy, + pandas, + keras, + tensorflow, + sklearn, + matplotlib, + scipy, + gnspy + +[flake8] +exclude = .git,__pycache__,docs,old,build,dist +max-complexity = 30 +max-line-length = 120 +#ignore=W504,F401,E402,E266,E203,W503,C408,C416,B001 + +[mypy] +check_untyped_defs = True +disallow_incomplete_defs = True +disallow_untyped_defs = True +warn_unused_configs = True +warn_no_return = True +warn_unreachable = False + +[pycodestyle] +count = False +#ignore = E226,E302,E41 +#max-line-length = 120 +statistics = True +# exclude = + +[pylint.config] +extension-pkg-whitelist= + numpy, + pandas, + keras, + tensorflow, + sklearn, + matplotlib, + scipy + +[pylint.MESSAGES CONTROL] +disable= + missing-docstring, + invalid-name, +#enable=E,W +jobs=1 +confidence=HIGH + +[pylint.FORMAT] +max-line-length = 120 +max-module-lines = 2000 + +[codespell] +skip = *.po,*.ts +count = +quiet-level = 3 diff --git a/setup.py b/setup.py index b780f35..ff9c639 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps setup""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import os @@ -35,7 +36,11 @@ def _read_descr(rel_path: str) -> str: def _read_reqs(rel_path: str) -> typing.List[str]: abs_path = os.path.join(os.path.dirname(__file__), rel_path) with open(abs_path, encoding="utf-8") as f: - return [s.strip() for s in f.readlines() if s.strip() and not s.strip().startswith("#")] + return [ + s.strip() + for s in f.readlines() + if s.strip() and not s.strip().startswith("#") and not s.strip().startswith("--requirement") + ] PACKAGE = "staticmaps" @@ -55,10 +60,12 @@ def _read_reqs(rel_path: str) -> typing.List[str]: "Topic :: Scientific/Engineering :: GIS", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", + # "Programming Language :: Python :: 3.6", + # "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], keywords="map staticmap osm markers", packages=[PACKAGE], diff --git a/staticmaps/__init__.py b/staticmaps/__init__.py index 860b0aa..b8da7f4 100644 --- a/staticmaps/__init__.py +++ b/staticmaps/__init__.py @@ -1,14 +1,13 @@ -# py-staticmaps +"""py-staticmaps __init__""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information # flake8: noqa from .area import Area +from .bounds import Bounds from .cairo_renderer import CairoRenderer, cairo_is_supported from .circle import Circle from .color import ( - parse_color, - random_color, - Color, BLACK, BLUE, BROWN, @@ -16,9 +15,12 @@ ORANGE, PURPLE, RED, - YELLOW, - WHITE, TRANSPARENT, + WHITE, + YELLOW, + Color, + parse_color, + random_color, ) from .context import Context from .coordinates import create_latlng, parse_latlng, parse_latlngs, parse_latlngs2rect @@ -33,13 +35,61 @@ from .tile_provider import ( TileProvider, default_tile_providers, + tile_provider_ArcGISWorldImagery, + tile_provider_CartoDarkNoLabels, + tile_provider_CartoNoLabels, + tile_provider_None, tile_provider_OSM, tile_provider_StamenTerrain, tile_provider_StamenToner, tile_provider_StamenTonerLite, - tile_provider_ArcGISWorldImagery, - tile_provider_CartoNoLabels, - tile_provider_CartoDarkNoLabels, - tile_provider_None, ) from .transformer import Transformer + +__all__ = [ + "Area", + "Bounds", + "CairoRenderer", + "cairo_is_supported", + "Circle", + "BLACK", + "BLUE", + "BROWN", + "GREEN", + "ORANGE", + "PURPLE", + "RED", + "TRANSPARENT", + "WHITE", + "YELLOW", + "Color", + "parse_color", + "random_color", + "Context", + "create_latlng", + "parse_latlng", + "parse_latlngs", + "parse_latlngs2rect", + "ImageMarker", + "Line", + "Marker", + "GITHUB_URL", + "LIB_NAME", + "VERSION", + "Object", + "PixelBoundsT", + "PillowRenderer", + "SvgRenderer", + "TileDownloader", + "TileProvider", + "default_tile_providers", + "tile_provider_ArcGISWorldImagery", + "tile_provider_CartoDarkNoLabels", + "tile_provider_CartoNoLabels", + "tile_provider_None", + "tile_provider_OSM", + "tile_provider_StamenTerrain", + "tile_provider_StamenToner", + "tile_provider_StamenTonerLite", + "Transformer", +] diff --git a/staticmaps/area.py b/staticmaps/area.py index ec4e0e0..ddd2c0d 100644 --- a/staticmaps/area.py +++ b/staticmaps/area.py @@ -1,14 +1,15 @@ -# py-staticmaps +"""py-staticmaps - area""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import typing +import s2sphere # type: ignore from PIL import Image as PIL_Image # type: ignore from PIL import ImageDraw as PIL_ImageDraw # type: ignore -import s2sphere # type: ignore from .cairo_renderer import CairoRenderer -from .color import Color, RED, TRANSPARENT +from .color import RED, TRANSPARENT, Color from .line import Line from .pillow_renderer import PillowRenderer from .svg_renderer import SvgRenderer @@ -17,7 +18,8 @@ class Area(Line): """Render an area using different renderers - :param master: A line object + Parameters: + master: A line object """ def __init__( @@ -32,16 +34,16 @@ def __init__( def fill_color(self) -> Color: """Return fill color of the area - :return: color object - :rtype: Color + Returns: + Color: color object """ return self._fill_color def render_pillow(self, renderer: PillowRenderer) -> None: """Render area using PILLOW - :param renderer: pillow renderer - :type renderer: PillowRenderer + Parameters: + renderer (PillowRenderer): pillow renderer """ xys = [ (x + renderer.offset_x(), y) @@ -57,8 +59,8 @@ def render_pillow(self, renderer: PillowRenderer) -> None: def render_svg(self, renderer: SvgRenderer) -> None: """Render area using svgwrite - :param renderer: svg renderer - :type renderer: SvgRenderer + Parameters: + renderer (SvgRenderer): svg renderer """ xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] @@ -82,8 +84,8 @@ def render_svg(self, renderer: SvgRenderer) -> None: def render_cairo(self, renderer: CairoRenderer) -> None: """Render area using cairo - :param renderer: cairo renderer - :type renderer: CairoRenderer + Parameters: + renderer (CairoRenderer): cairo renderer """ xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] diff --git a/staticmaps/bounds.py b/staticmaps/bounds.py new file mode 100644 index 0000000..897d89b --- /dev/null +++ b/staticmaps/bounds.py @@ -0,0 +1,73 @@ +"""py-staticmaps - bounds""" + +# Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information + +import typing + +import s2sphere # type: ignore + +from .cairo_renderer import CairoRenderer +from .object import Object, PixelBoundsT +from .pillow_renderer import PillowRenderer +from .svg_renderer import SvgRenderer + + +class Bounds(Object): + """ + Custom bounds object to be respected for the final static map. Nothing is being rendered. + """ + + def __init__( + self, latlngs: typing.List[s2sphere.LatLng], extra_pixel_bounds: typing.Union[int, PixelBoundsT] = 0 + ) -> None: + Object.__init__(self) + if latlngs is None or len(latlngs) < 2: + raise ValueError("Trying to create custom bounds with less than 2 coordinates") + self._latlngs = latlngs + if isinstance(extra_pixel_bounds, int): + self._extra_pixel_bounds = (extra_pixel_bounds, extra_pixel_bounds, extra_pixel_bounds, extra_pixel_bounds) + else: + self._extra_pixel_bounds = extra_pixel_bounds + + def bounds(self) -> s2sphere.LatLngRect: + """Return bounds of bounds object + + Returns: + s2sphere.LatLngRect: bounds of bounds object + """ + b = s2sphere.LatLngRect() + for latlng in self._latlngs: + b = b.union(s2sphere.LatLngRect.from_point(latlng.normalized())) + return b + + def extra_pixel_bounds(self) -> PixelBoundsT: + """Return extra pixel bounds of bounds object + + Returns: + PixelBoundsT: extra pixel bounds + """ + return self._extra_pixel_bounds + + def render_pillow(self, renderer: PillowRenderer) -> None: + """Do not render custom bounds for pillow + + Parameters: + renderer (PillowRenderer): pillow renderer + """ + return + + def render_svg(self, renderer: SvgRenderer) -> None: + """Do not render custom bounds for svg + + Parameters: + renderer (SvgRenderer): svg renderer + """ + return + + def render_cairo(self, renderer: CairoRenderer) -> None: + """Do not render custom bounds for cairo + + Parameters: + renderer (CairoRenderer): cairo renderer + """ + return diff --git a/staticmaps/cairo_renderer.py b/staticmaps/cairo_renderer.py index 8f60a6e..89753e0 100644 --- a/staticmaps/cairo_renderer.py +++ b/staticmaps/cairo_renderer.py @@ -1,3 +1,5 @@ +"""py-staticmaps CairoRenderer""" + # py-staticmaps # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information @@ -6,6 +8,8 @@ import sys import typing +# import s2sphere # type: ignore + try: import cairo # type: ignore except ImportError: @@ -13,7 +17,7 @@ from PIL import Image as PIL_Image # type: ignore -from .color import Color, BLACK, WHITE +from .color import BLACK, WHITE, Color from .renderer import Renderer from .transformer import Transformer @@ -25,8 +29,8 @@ def cairo_is_supported() -> bool: """Check whether cairo is supported - :return: Is cairo supported - :rtype: bool + Returns: + bool: Is cairo supported """ return "cairo" in sys.modules @@ -50,17 +54,15 @@ def __init__(self, transformer: Transformer) -> None: def image_surface(self) -> cairo_ImageSurface: """ - - :return: cairo image surface - :rtype: cairo.ImageSurface + Returns: + cairo.ImageSurface: cairo image surface """ return self._surface def context(self) -> cairo_Context: """ - - :return: cairo context - :rtype: cairo.Context + Returns: + cairo.Context: cairo context """ return self._context @@ -68,11 +70,11 @@ def context(self) -> cairo_Context: def create_image(image_data: bytes) -> cairo_ImageSurface: """Create a cairo image - :param image_data: Image data - :type image_data: bytes + Parameters: + image_data (bytes): Image data - :return: cairo image surface - :rtype: cairo.ImageSurface + Returns: + cairo.ImageSurface: cairo image surface """ image = PIL_Image.open(io.BytesIO(image_data)) if image.format == "PNG": @@ -83,11 +85,16 @@ def create_image(image_data: bytes) -> cairo_ImageSurface: png_bytes.seek(0) return cairo.ImageSurface.create_from_png(png_bytes) - def render_objects(self, objects: typing.List["Object"]) -> None: + def render_objects( + self, + objects: typing.List["Object"], + tighten: bool, + ) -> None: """Render all objects of static map - :param objects: objects of static map - :type objects: typing.List["Object"] + Parameters: + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) for obj in objects: @@ -100,8 +107,8 @@ def render_objects(self, objects: typing.List["Object"]) -> None: def render_background(self, color: typing.Optional[Color]) -> None: """Render background of static map - :param color: background color - :type color: typing.Optional[Color] + Parameters: + color (typing.Optional[Color]): background color """ if color is None: return @@ -109,11 +116,18 @@ def render_background(self, color: typing.Optional[Color]) -> None: self._context.rectangle(0, 0, *self._trans.image_size()) self._context.fill() - def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: - """Render background of static map - - :param download: url of tiles provider - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] + def render_tiles( + self, + download: typing.Callable[[int, int, int], typing.Optional[bytes]], + objects: typing.List["Object"], + tighten: bool, + ) -> None: + """Render tiles of static map + + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): url of tiles provider + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ for yy in range(0, self._trans.tiles_y()): y = self._trans.first_tile_y() + yy @@ -127,8 +141,8 @@ def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optiona continue self._context.save() self._context.translate( - xx * self._trans.tile_size() + self._trans.tile_offset_x(), - yy * self._trans.tile_size() + self._trans.tile_offset_y(), + int(xx * self._trans.tile_size() + self._trans.tile_offset_x()), + int(yy * self._trans.tile_size() + self._trans.tile_offset_y()), ) self._context.set_source_surface(tile_img) self._context.paint() @@ -139,8 +153,8 @@ def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optiona def render_attribution(self, attribution: typing.Optional[str]) -> None: """Render attribution from given tiles provider - :param attribution: Attribution for the given tiles provider - :type attribution: typing.Optional[str]: + Parameters: + attribution (typing.Optional[str]:): Attribution for the given tiles provider """ if (attribution is None) or (attribution == ""): return @@ -150,10 +164,10 @@ def render_attribution(self, attribution: typing.Optional[str]) -> None: while True: self._context.set_font_size(font_size) _, f_descent, f_height, _, _ = self._context.font_extents() - t_width = self._context.text_extents(attribution)[3] + t_width = self._context.text_extents(attribution).width if t_width < width - 4: break - font_size = font_size - 0.25 + font_size -= 0.25 self._context.set_source_rgba(*WHITE.float_rgb(), 0.8) self._context.rectangle(0, height - f_height - f_descent - 2, width, height) self._context.fill() @@ -168,15 +182,14 @@ def fetch_tile( ) -> typing.Optional[cairo_ImageSurface]: """Fetch tiles from given tiles provider - :param download: callable - :param x: width - :param y: height - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] - :type x: int - :type y: int + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): + callable + x (int): width + y (int): height - :return: cairo image surface - :rtype: typing.Optional[cairo_ImageSurface] + Returns: + typing.Optional[cairo_ImageSurface]: cairo image surface """ image_data = download(self._trans.zoom(), x, y) if image_data is None: diff --git a/staticmaps/circle.py b/staticmaps/circle.py index d2ec92e..489eeca 100644 --- a/staticmaps/circle.py +++ b/staticmaps/circle.py @@ -1,20 +1,22 @@ -# py-staticmaps +"""py-staticmaps - circle""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import typing -from geographiclib.geodesic import Geodesic # type: ignore import s2sphere # type: ignore +from geographiclib.geodesic import Geodesic # type: ignore from .area import Area -from .color import Color, RED, TRANSPARENT +from .color import RED, TRANSPARENT, Color from .coordinates import create_latlng class Circle(Area): """Render a circle using different renderers - :param master: an area object + Parameters: + master: an area object """ def __init__( @@ -31,13 +33,12 @@ def __init__( def compute_circle(center: s2sphere.LatLng, radius_km: float) -> typing.Iterator[s2sphere.LatLng]: """Compute a circle with given center and radius - :param center: Center of the circle - :param radius_km: Radius of the circle - :type center: s2sphere.LatLng - :type radius_km: float + Parameters: + center (s2sphere.LatLng): Center of the circle + radius_km (float): Radius of the circle - :return: circle - :rtype: typing.Iterator[s2sphere.LatLng] + Yields: + typing.Iterator[s2sphere.LatLng]: circle """ first = None delta_angle = 0.1 @@ -55,6 +56,6 @@ def compute_circle(center: s2sphere.LatLng, radius_km: float) -> typing.Iterator if first is None: first = latlng yield latlng - angle = angle + delta_angle + angle += delta_angle if first: yield first diff --git a/staticmaps/cli.py b/staticmaps/cli.py index 62b37eb..b7a9000 100755 --- a/staticmaps/cli.py +++ b/staticmaps/cli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python +"""py-staticmaps - cli.py - entry point""" -# py-staticmaps # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import argparse @@ -11,12 +11,27 @@ class FileFormat(enum.Enum): + """FileFormat""" + GUESS = "guess" PNG = "png" SVG = "svg" def determine_file_format(file_format: FileFormat, file_name: str) -> FileFormat: + """ + determine_file_format Try to determine the file format + + Parameters: + file_format (FileFormat): The File Format + file_name (str): The file name + + Raises: + RuntimeError: If the file format cannot be determined + + Returns: + FileFormat: A FileFormat object + """ if file_format != FileFormat.GUESS: return file_format extension = os.path.splitext(file_name)[1] @@ -28,6 +43,7 @@ def determine_file_format(file_format: FileFormat, file_name: str) -> FileFormat def main() -> None: + """main Entry point""" args_parser = argparse.ArgumentParser(prog="createstaticmap") args_parser.add_argument( "--center", @@ -79,6 +95,12 @@ def main() -> None: metavar="LAT,LNG LAT,LNG", type=str, ) + args_parser.add_argument( + "--tight-to-bounds", + action="store_true", + default=False, + help="Tighten static map to minimum boundaries of objects, custom boundaries, ...(default: False)", + ) args_parser.add_argument( "--tiles", metavar="TILEPROVIDER", @@ -129,7 +151,8 @@ def main() -> None: for coords in args.marker: context.add_object(staticmaps.Marker(staticmaps.parse_latlng(coords))) if args.bounds is not None: - context.add_bounds(staticmaps.parse_latlngs2rect(args.bounds)) + context.add_object(staticmaps.Bounds(staticmaps.parse_latlngs(args.bounds))) + context.set_tighten_to_bounds(args.tighten_to_bounds) file_name = args.filename[0] if determine_file_format(args.file_format, file_name) == FileFormat.PNG: diff --git a/staticmaps/color.py b/staticmaps/color.py index 0c127d8..6f984dc 100644 --- a/staticmaps/color.py +++ b/staticmaps/color.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - Color""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import random @@ -26,8 +27,8 @@ def __init__(self, r: int, g: int, b: int, a: int = 255): def text_color(self) -> "Color": """Return text color depending on luminance - :return: a color depending on luminance - :rtype: Color + Returns: + Color: a color depending on luminance """ luminance = 0.299 * self._r + 0.587 * self._g + 0.114 * self._b return BLACK if luminance >= 0x7F else WHITE @@ -35,34 +36,49 @@ def text_color(self) -> "Color": def hex_rgb(self) -> str: """Return color in rgb hex values - :return: color in rgb hex values - :rtype:str + Returns: + str: color in rgb hex values """ return f"#{self._r:02x}{self._g:02x}{self._b:02x}" def int_rgb(self) -> typing.Tuple[int, int, int]: """Return color in int values - :return: color in int values - :rtype:tuple + Returns: + tuple: color in int values """ return self._r, self._g, self._b def int_rgba(self) -> typing.Tuple[int, int, int, int]: """Return color in rgba int values with transparency - :return: color in rgba int values - :rtype:tuple + Returns: + tuple: color in rgba int values """ return self._r, self._g, self._b, self._a def float_rgb(self) -> typing.Tuple[float, float, float]: + """Return color in rgb float values + + Returns: + tuple: color in rgb float values + """ return self._r / 255.0, self._g / 255.0, self._b / 255.0 def float_rgba(self) -> typing.Tuple[float, float, float, float]: + """Return color in rgba float values with transparency + + Returns: + tuple: color in rgba float values + """ return self._r / 255.0, self._g / 255.0, self._b / 255.0, self._a / 255.0 def float_a(self) -> float: + """Return alpha channel as float value + + Returns: + float: alpha channel as float value + """ return self._a / 255.0 @@ -79,6 +95,18 @@ def float_a(self) -> float: def parse_color(s: str) -> Color: + """ + parse_color Parse a string to a color object + + Parameters: + s (str): A string representing a color + + Raises: + ValueError: If string is not recognised as a color representation + + Returns: + Color: A Color object + """ re_rgb = re.compile(r"^(0x|#)([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$") re_rgba = re.compile(r"^(0x|#)([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$") @@ -120,6 +148,12 @@ def parse_color(s: str) -> Color: def random_color() -> Color: + """ + random_color Return a color object of a random color + + Returns: + Color: A Color object + """ return random.choice( [ BLACK, diff --git a/staticmaps/context.py b/staticmaps/context.py index 525c27d..55f09ac 100644 --- a/staticmaps/context.py +++ b/staticmaps/context.py @@ -1,5 +1,7 @@ +"""py-staticmaps - Context""" + # py-staticmaps -# Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information +# Copyright (c) 2022 Florian Pigorsch; see /LICENSE for licensing information import math import os @@ -22,24 +24,27 @@ class Context: + """Context""" + # pylint: disable=too-many-instance-attributes def __init__(self) -> None: self._background_color: typing.Optional[Color] = None self._objects: typing.List[Object] = [] self._center: typing.Optional[s2sphere.LatLng] = None - self._bounds: typing.Optional[s2sphere.LatLngRect] = None - self._extra_pixel_bounds: typing.Tuple[int, int, int, int] = (0, 0, 0, 0) self._zoom: typing.Optional[int] = None self._tile_provider = tile_provider_OSM self._tile_downloader = TileDownloader() self._cache_dir = os.path.join(appdirs.user_cache_dir(LIB_NAME), "tiles") + self._tighten_to_bounds: bool = False def set_zoom(self, zoom: int) -> None: """Set zoom for static map - :param zoom: zoom for static map - :type zoom: int - :raises ValueError: raises value error for invalid zoom factors + Parameters: + zoom (int): zoom for static map + + Raises: + ValueError: raises value error for invalid zoom factors """ if zoom < 0 or zoom > 30: raise ValueError(f"Bad zoom value: {zoom}") @@ -48,90 +53,76 @@ def set_zoom(self, zoom: int) -> None: def set_center(self, latlng: s2sphere.LatLng) -> None: """Set center for static map - :param latlng: zoom for static map - :type latlng: s2sphere.LatLng + Parameters: + latlng (s2sphere.LatLng): zoom for static map """ self._center = latlng def set_background_color(self, color: Color) -> None: """Set background color for static map - :param color: background color for static map - :type color: s2sphere.LatLng + Parameters: + color (s2sphere.LatLng): background color for static map """ self._background_color = color def set_cache_dir(self, directory: str) -> None: """Set cache dir - :param directory: cache directory - :type directory: str + Parameters: + directory (str): cache directory """ self._cache_dir = directory def set_tile_downloader(self, downloader: TileDownloader) -> None: """Set tile downloader - :param downloader: tile downloader - :type downloader: TileDownloader + Parameters: + downloader (TileDownloader): tile downloader """ self._tile_downloader = downloader def set_tile_provider(self, provider: TileProvider, api_key: typing.Optional[str] = None) -> None: """Set tile provider - :param provider: tile provider - :type provider: TileProvider - :param api_key: api key (if needed) - :type api_key: str + Parameters: + provider (TileProvider): tile provider + api_key (str): api key (if needed) """ self._tile_provider = provider if api_key: self._tile_provider.set_api_key(api_key) + def set_tighten_to_bounds(self, tighten: bool = False) -> None: + """Set tighten to bounds + + Parameters: + tighten (bool): tighten or not + """ + self._tighten_to_bounds = tighten + def add_object(self, obj: Object) -> None: """Add object for the static map (e.g. line, area, marker) - :param obj: map object - :type obj: Object + Parameters: + obj (Object): map object """ self._objects.append(obj) - def add_bounds( - self, - latlngrect: s2sphere.LatLngRect, - extra_pixel_bounds: typing.Optional[typing.Union[int, typing.Tuple[int, int, int, int]]] = None, - ) -> None: - """Add boundaries that shall be respected by the static map - - :param latlngrect: boundaries to be respected - :type latlngrect: s2sphere.LatLngRect - :param extra_pixel_bounds: extra pixel bounds to be respected - :type extra_pixel_bounds: int, tuple - """ - self._bounds = latlngrect - if extra_pixel_bounds: - if isinstance(extra_pixel_bounds, tuple): - self._extra_pixel_bounds = extra_pixel_bounds - else: - self._extra_pixel_bounds = ( - extra_pixel_bounds, - extra_pixel_bounds, - extra_pixel_bounds, - extra_pixel_bounds, - ) - def render_cairo(self, width: int, height: int) -> typing.Any: """Render area using cairo - :param width: width of static map - :type width: int - :param height: height of static map - :type height: int - :return: cairo image - :rtype: cairo.ImageSurface - :raises RuntimeError: raises runtime error if cairo is not available - :raises RuntimeError: raises runtime error if map has no center and zoom + Parameters: + width (int): width of static map + height (int): height of static map + + Returns: + cairo.ImageSurface: cairo image + + Raises: + RuntimeError: raises runtime error if cairo is not available + RuntimeError: raises runtime error if map has no center and + zoom """ if not cairo_is_supported(): raise RuntimeError('You need to install the "cairo" module to enable "render_cairo".') @@ -144,8 +135,8 @@ def render_cairo(self, width: int, height: int) -> typing.Any: renderer = CairoRenderer(trans) renderer.render_background(self._background_color) - renderer.render_tiles(self._fetch_tile) - renderer.render_objects(self._objects) + renderer.render_tiles(self._fetch_tile, self._objects, self._tighten_to_bounds) + renderer.render_objects(self._objects, self._tighten_to_bounds) renderer.render_attribution(self._tile_provider.attribution()) return renderer.image_surface() @@ -153,13 +144,15 @@ def render_cairo(self, width: int, height: int) -> typing.Any: def render_pillow(self, width: int, height: int) -> PIL_Image.Image: """Render context using PILLOW - :param width: width of static map - :type width: int - :param height: height of static map - :type height: int - :return: pillow image - :rtype: PIL_Image - :raises RuntimeError: raises runtime error if map has no center and zoom + Parameters: + width (int): width of static map + height (int): height of static map + + Returns: + PIL_Image: pillow image + + Raises: + RuntimeError: raises runtime error if map has no center and zoom """ center, zoom = self.determine_center_zoom(width, height) if center is None or zoom is None: @@ -169,8 +162,8 @@ def render_pillow(self, width: int, height: int) -> PIL_Image.Image: renderer = PillowRenderer(trans) renderer.render_background(self._background_color) - renderer.render_tiles(self._fetch_tile) - renderer.render_objects(self._objects) + renderer.render_tiles(self._fetch_tile, self._objects, self._tighten_to_bounds) + renderer.render_objects(self._objects, self._tighten_to_bounds) renderer.render_attribution(self._tile_provider.attribution()) return renderer.image() @@ -178,13 +171,15 @@ def render_pillow(self, width: int, height: int) -> PIL_Image.Image: def render_svg(self, width: int, height: int) -> svgwrite.Drawing: """Render context using svgwrite - :param width: width of static map - :type width: int - :param height: height of static map - :type height: int - :return: svg drawing - :rtype: svgwrite.Drawing - :raises RuntimeError: raises runtime error if map has no center and zoom + Parameters: + width (int): width of static map + height (int): height of static map + + Returns: + svgwrite.Drawing: svg drawing + + Raises: + RuntimeError: raises runtime error if map has no center and zoom """ center, zoom = self.determine_center_zoom(width, height) if center is None or zoom is None: @@ -194,8 +189,8 @@ def render_svg(self, width: int, height: int) -> svgwrite.Drawing: renderer = SvgRenderer(trans) renderer.render_background(self._background_color) - renderer.render_tiles(self._fetch_tile) - renderer.render_objects(self._objects) + renderer.render_tiles(self._fetch_tile, self._objects, self._tighten_to_bounds) + renderer.render_objects(self._objects, self._tighten_to_bounds) renderer.render_attribution(self._tile_provider.attribution()) return renderer.drawing() @@ -203,8 +198,8 @@ def render_svg(self, width: int, height: int) -> svgwrite.Drawing: def object_bounds(self) -> typing.Optional[s2sphere.LatLngRect]: """return maximum bounds of all objects - :return: maximum of all object bounds - :rtype: s2sphere.LatLngRect + Returns: + s2sphere.LatLngRect: maximum of all object bounds """ bounds = None if len(self._objects) != 0: @@ -212,32 +207,18 @@ def object_bounds(self) -> typing.Optional[s2sphere.LatLngRect]: for obj in self._objects: assert bounds bounds = bounds.union(obj.bounds()) - return self._custom_bounds(bounds) - - def _custom_bounds(self, bounds: typing.Optional[s2sphere.LatLngRect]) -> typing.Optional[s2sphere.LatLngRect]: - """check for additional bounds and return the union with object bounds - - :param bounds: boundaries from objects - :type bounds: s2sphere.LatLngRect - :return: maximum of additional and object bounds - :rtype: s2sphere.LatLngRect - """ - if not self._bounds: - return bounds - if not bounds: - return self._bounds - return bounds.union(self._bounds) + return bounds def extra_pixel_bounds(self) -> PixelBoundsT: """return extra pixel bounds from all objects - :return: extra pixel object bounds - :rtype: PixelBoundsT + Returns: + PixelBoundsT: extra pixel object bounds """ - max_l, max_t, max_r, max_b = self._extra_pixel_bounds + max_l, max_t, max_r, max_b = 0, 0, 0, 0 attribution = self._tile_provider.attribution() if (attribution is None) or (attribution == ""): - max_b = 12 + max_b = max(max_b, 12) for obj in self._objects: (l, t, r, b) = obj.extra_pixel_bounds() max_l = max(max_l, l) @@ -251,12 +232,12 @@ def determine_center_zoom( ) -> typing.Tuple[typing.Optional[s2sphere.LatLng], typing.Optional[int]]: """return center and zoom of static map - :param width: width of static map - :param height: height of static map - :type width: int - :type height: int - :return: center, zoom - :rtype: tuple + Parameters: + width (int): width of static map + height (int): height of static map + + Returns: + tuple: center, zoom """ if self._center is not None: if self._zoom is not None: diff --git a/staticmaps/coordinates.py b/staticmaps/coordinates.py index 124fd87..f2650fb 100644 --- a/staticmaps/coordinates.py +++ b/staticmaps/coordinates.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - Coordinates""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import typing @@ -9,12 +10,12 @@ def create_latlng(lat: float, lng: float) -> s2sphere.LatLng: """Create a LatLng object from float values - :param lat: latitude - :type lat: float - :param lng: longitude - :type lng: float - :return: LatLng object - :rtype: s2sphere.LatLng + Parameters: + lat (float): latitude + lng (float): longitude + + Returns: + s2sphere.LatLng: LatLng object """ return s2sphere.LatLng.from_degrees(lat, lng) @@ -22,11 +23,14 @@ def create_latlng(lat: float, lng: float) -> s2sphere.LatLng: def parse_latlng(s: str) -> s2sphere.LatLng: """Parse a string with comma separated latitude,longitude values and create a LatLng object from float values - :param s: string with latitude,longitude values - :type s: str - :return: LatLng object - :rtype: s2sphere.LatLng - :raises ValueError: raises a value error if the format is wrong + Parameters: + s (str): string with latitude,longitude values + + Returns: + s2sphere.LatLng: LatLng object + + Raises: + ValueError: raises a value error if the format is wrong """ a = s.split(",") if len(a) != 2: @@ -47,10 +51,12 @@ def parse_latlng(s: str) -> s2sphere.LatLng: def parse_latlngs(s: str) -> typing.List[s2sphere.LatLng]: """Parse a string with multiple comma separated latitude,longitude values and create a list of LatLng objects - :param s: string with multiple latitude,longitude values separated with empty space - :type s: str - :return: list of LatLng objects - :rtype: typing.List[s2sphere.LatLng] + Parameters: + s (str): string with multiple latitude,longitude values + separated with empty space + + Returns: + typing.List[s2sphere.LatLng]: list of LatLng objects """ res = [] for c in s.split(): @@ -64,11 +70,15 @@ def parse_latlngs2rect(s: str) -> s2sphere.LatLngRect: """Parse a string with two comma separated latitude,longitude values and create a LatLngRect object - :param s: string with two latitude,longitude values separated with empty space - :type s: str - :return: LatLngRect from LatLng pair - :rtype: s2sphere.LatLngRect - :raises ValueError: exactly two lat/lng pairs must be given as argument + Parameters: + s (str): string with two latitude,longitude values separated + with empty space + + Returns: + s2sphere.LatLngRect: LatLngRect from LatLng pair + + Raises: + ValueError: exactly two lat/lng pairs must be given as argument """ latlngs = parse_latlngs(s) if len(latlngs) != 2: diff --git a/staticmaps/image_marker.py b/staticmaps/image_marker.py index ad6634f..a4aedea 100644 --- a/staticmaps/image_marker.py +++ b/staticmaps/image_marker.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - image_marker""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import io @@ -14,6 +15,10 @@ class ImageMarker(Object): + """ + ImageMarker A marker for an image object + """ + def __init__(self, latlng: s2sphere.LatLng, png_file: str, origin_x: int, origin_y: int) -> None: Object.__init__(self) self._latlng = latlng @@ -27,24 +32,24 @@ def __init__(self, latlng: s2sphere.LatLng, png_file: str, origin_x: int, origin def origin_x(self) -> int: """Return x origin of the image marker - :return: x origin of the image marker - :rtype: int + Returns: + int: x origin of the image marker """ return self._origin_x def origin_y(self) -> int: """Return y origin of the image marker - :return: y origin of the image marker - :rtype: int + Returns: + int: y origin of the image marker """ return self._origin_y def width(self) -> int: """Return width of the image marker - :return: width of the image marker - :rtype: int + Returns: + int: width of the image marker """ if self._image_data is None: self.load_image_data() @@ -53,8 +58,8 @@ def width(self) -> int: def height(self) -> int: """Return height of the image marker - :return: height of the image marker - :rtype: int + Returns: + int: height of the image marker """ if self._image_data is None: self.load_image_data() @@ -63,8 +68,8 @@ def height(self) -> int: def image_data(self) -> bytes: """Return image data of the image marker - :return: image data of the image marker - :rtype: bytes + Returns: + bytes: image data of the image marker """ if self._image_data is None: self.load_image_data() @@ -74,24 +79,24 @@ def image_data(self) -> bytes: def latlng(self) -> s2sphere.LatLng: """Return LatLng of the image marker - :return: LatLng of the image marker - :rtype: s2sphere.LatLng + Returns: + s2sphere.LatLng: LatLng of the image marker """ return self._latlng def bounds(self) -> s2sphere.LatLngRect: """Return bounds of the image marker - :return: bounds of the image marker - :rtype: s2sphere.LatLngRect + Returns: + s2sphere.LatLngRect: bounds of the image marker """ return s2sphere.LatLngRect.from_point(self._latlng) def extra_pixel_bounds(self) -> PixelBoundsT: """Return extra pixel bounds of the image marker - :return: extra pixel bounds of the image marker - :rtype: PixelBoundsT + Returns: + PixelBoundsT: extra pixel bounds of the image marker """ return ( max(0, self._origin_x), @@ -103,8 +108,8 @@ def extra_pixel_bounds(self) -> PixelBoundsT: def render_pillow(self, renderer: PillowRenderer) -> None: """Render marker using PILLOW - :param renderer: pillow renderer - :type renderer: PillowRenderer + Parameters: + renderer (PillowRenderer): pillow renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) image = renderer.create_image(self.image_data()) @@ -122,8 +127,8 @@ def render_pillow(self, renderer: PillowRenderer) -> None: def render_svg(self, renderer: SvgRenderer) -> None: """Render marker using svgwrite - :param renderer: svg renderer - :type renderer: SvgRenderer + Parameters: + renderer (SvgRenderer): svg renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) image = renderer.create_inline_image(self.image_data()) @@ -139,8 +144,8 @@ def render_svg(self, renderer: SvgRenderer) -> None: def render_cairo(self, renderer: CairoRenderer) -> None: """Render marker using cairo - :param renderer: cairo renderer - :type renderer: CairoRenderer + Parameters: + renderer (CairoRenderer): cairo renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) image = renderer.create_image(self.image_data()) diff --git a/staticmaps/line.py b/staticmaps/line.py index f5fd11f..55e9997 100644 --- a/staticmaps/line.py +++ b/staticmaps/line.py @@ -1,23 +1,30 @@ -# py-staticmaps +"""py-staticmaps - line""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import math import typing -from geographiclib.geodesic import Geodesic # type: ignore import s2sphere # type: ignore +from geographiclib.geodesic import Geodesic # type: ignore -from .color import Color, RED +from .bounds import Bounds +from .cairo_renderer import CairoRenderer +from .color import RED, Color from .coordinates import create_latlng from .object import Object, PixelBoundsT -from .cairo_renderer import CairoRenderer from .pillow_renderer import PillowRenderer from .svg_renderer import SvgRenderer -class Line(Object): +class Line(Bounds): + """ + Line A line object + """ + + # pylint: disable=super-init-not-called def __init__(self, latlngs: typing.List[s2sphere.LatLng], color: Color = RED, width: int = 2) -> None: - Object.__init__(self) + Object.__init__(self) # pylint: disable=non-parent-init-called if latlngs is None or len(latlngs) < 2: raise ValueError("Trying to create line with less than 2 coordinates") if width < 0: @@ -31,24 +38,24 @@ def __init__(self, latlngs: typing.List[s2sphere.LatLng], color: Color = RED, wi def color(self) -> Color: """Return color of the line - :return: color object - :rtype: Color + Returns: + Color: color object """ return self._color def width(self) -> int: """Return width of line - :return: width - :rtype: int + Returns: + int: width """ return self._width def bounds(self) -> s2sphere.LatLngRect: """Return bounds of line - :return: bounds of line - :rtype: s2sphere.LatLngRect + Returns: + s2sphere.LatLngRect: bounds of line """ b = s2sphere.LatLngRect() for latlng in self.interpolate(): @@ -58,16 +65,16 @@ def bounds(self) -> s2sphere.LatLngRect: def extra_pixel_bounds(self) -> PixelBoundsT: """Return extra pixel bounds from line - :return: extra pixel bounds - :rtype: PixelBoundsT + Returns: + PixelBoundsT: extra pixel bounds """ - return self._width, self._width, self._width, self._width + return int(0.5 * self._width), int(0.5 * self._width), int(0.5 * self._width), int(0.5 * self._width) def interpolate(self) -> typing.List[s2sphere.LatLng]: """Interpolate bounds - :return: list of LatLng - :rtype: typing.List[s2sphere.LatLng] + Returns: + typing.List[s2sphere.LatLng]: list of LatLng """ if self._interpolation_cache is not None: return self._interpolation_cache @@ -106,8 +113,8 @@ def interpolate(self) -> typing.List[s2sphere.LatLng]: def render_pillow(self, renderer: PillowRenderer) -> None: """Render line using PILLOW - :param renderer: pillow renderer - :type renderer: PillowRenderer + Parameters: + renderer (PillowRenderer): pillow renderer """ if self.width() == 0: return @@ -120,8 +127,8 @@ def render_pillow(self, renderer: PillowRenderer) -> None: def render_svg(self, renderer: SvgRenderer) -> None: """Render line using svgwrite - :param renderer: svg renderer - :type renderer: SvgRenderer + Parameters: + renderer (SvgRenderer): svg renderer """ if self.width() == 0: return @@ -138,8 +145,8 @@ def render_svg(self, renderer: SvgRenderer) -> None: def render_cairo(self, renderer: CairoRenderer) -> None: """Render line using cairo - :param renderer: cairo renderer - :type renderer: CairoRenderer + Parameters: + renderer (CairoRenderer): cairo renderer """ if self.width() == 0: return diff --git a/staticmaps/marker.py b/staticmaps/marker.py index 961893b..8924529 100644 --- a/staticmaps/marker.py +++ b/staticmaps/marker.py @@ -1,72 +1,95 @@ -# py-staticmaps +"""py-staticmaps - marker""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import math import s2sphere # type: ignore -from .color import Color, RED -from .object import Object, PixelBoundsT from .cairo_renderer import CairoRenderer +from .color import RED, Color +from .object import Object, PixelBoundsT from .pillow_renderer import PillowRenderer from .svg_renderer import SvgRenderer class Marker(Object): - def __init__(self, latlng: s2sphere.LatLng, color: Color = RED, size: int = 10) -> None: + """ + Marker A marker object. + The given parameter size is the radius of the rounded head of the marker + The final marker object size is: + width = 2 * size + height = 3 * size + """ + + def __init__(self, latlng: s2sphere.LatLng, color: Color = RED, size: int = 10, stroke_width: int = 1) -> None: Object.__init__(self) self._latlng = latlng self._color = color self._size = size + self._stroke_width = stroke_width def latlng(self) -> s2sphere.LatLng: """Return LatLng of the marker - :return: LatLng of the marker - :rtype: s2sphere.LatLng + Returns: + s2sphere.LatLng: LatLng of the marker """ return self._latlng def color(self) -> Color: """Return color of the marker - :return: color object - :rtype: Color + Returns: + Color: color object """ return self._color def size(self) -> int: """Return size of the marker - :return: size of the marker - :rtype: int + Returns: + int: size of the marker """ return self._size + def stroke_width(self) -> int: + """Return stroke width of the marker + + Returns: + int: stroke width of the marker + """ + return self._stroke_width + def bounds(self) -> s2sphere.LatLngRect: """Return bounds of the marker - :return: bounds of the marker - :rtype: s2sphere.LatLngRect + Returns: + s2sphere.LatLngRect: bounds of the marker """ - return s2sphere.LatLngRect.from_point(self._latlng) + return s2sphere.LatLngRect.from_point(self.latlng()) def extra_pixel_bounds(self) -> PixelBoundsT: """Return extra pixel bounds of the marker - :return: extra pixel bounds of the marker - :rtype: PixelBoundsT + Returns: + PixelBoundsT: extra pixel bounds of the marker """ - return self._size, self._size, self._size, 0 + return ( + int(self.size() + 0.5 * self.stroke_width()), + int(3 * self.size() + 0.5 * self.stroke_width()), + int(self.size() + 0.5 * self.stroke_width()), + int(0.5 * self.stroke_width()), + ) def render_pillow(self, renderer: PillowRenderer) -> None: """Render marker using PILLOW - :param renderer: pillow renderer - :type renderer: PillowRenderer + Parameters: + renderer (PillowRenderer): pillow renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) - x = x + renderer.offset_x() + x += renderer.offset_x() r = self.size() dx = math.sin(math.pi / 3.0) @@ -89,8 +112,8 @@ def render_pillow(self, renderer: PillowRenderer) -> None: def render_svg(self, renderer: SvgRenderer) -> None: """Render marker using svgwrite - :param renderer: svg renderer - :type renderer: SvgRenderer + Parameters: + renderer (SvgRenderer): svg renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) r = self.size() @@ -99,7 +122,7 @@ def render_svg(self, renderer: SvgRenderer) -> None: path = renderer.drawing().path( fill=self.color().hex_rgb(), stroke=self.color().text_color().hex_rgb(), - stroke_width=1, + stroke_width=self.stroke_width(), opacity=self.color().float_a(), ) path.push(f"M {x} {y}") @@ -111,8 +134,8 @@ def render_svg(self, renderer: SvgRenderer) -> None: def render_cairo(self, renderer: CairoRenderer) -> None: """Render marker using cairo - :param renderer: cairo renderer - :type renderer: CairoRenderer + Parameters: + renderer (CairoRenderer): cairo renderer """ x, y = renderer.transformer().ll2pixel(self.latlng()) r = self.size() diff --git a/staticmaps/meta.py b/staticmaps/meta.py index f5be0ec..c19db2a 100644 --- a/staticmaps/meta.py +++ b/staticmaps/meta.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - meta""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information GITHUB_URL = "https://github.com/flopp/py-staticmaps" diff --git a/staticmaps/object.py b/staticmaps/object.py index 7620005..d8a79f0 100644 --- a/staticmaps/object.py +++ b/staticmaps/object.py @@ -1,8 +1,9 @@ -# py-staticmaps +"""py-staticmaps - object""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information -from abc import ABC, abstractmethod import typing +from abc import ABC, abstractmethod import s2sphere # type: ignore @@ -11,11 +12,14 @@ from .svg_renderer import SvgRenderer from .transformer import Transformer - PixelBoundsT = typing.Tuple[int, int, int, int] class Object(ABC): + """ + Object A base class for objects + """ + def __init__(self) -> None: pass @@ -23,8 +27,8 @@ def __init__(self) -> None: def extra_pixel_bounds(self) -> PixelBoundsT: """Return extra pixel bounds from object - :return: extra pixel bounds - :rtype: PixelBoundsT + Returns: + PixelBoundsT: extra pixel bounds """ return 0, 0, 0, 0 @@ -32,17 +36,19 @@ def extra_pixel_bounds(self) -> PixelBoundsT: def bounds(self) -> s2sphere.LatLngRect: """Return bounds of object - :return: bounds of object - :rtype: s2sphere.LatLngRect + Returns: + s2sphere.LatLngRect: bounds of object """ return s2sphere.LatLngRect() def render_pillow(self, renderer: PillowRenderer) -> None: """Render object using PILLOW - :param renderer: pillow renderer - :type renderer: PillowRenderer - :raises RuntimeError: raises runtime error if a not implemented method is called + Parameters: + renderer (PillowRenderer): pillow renderer + + Raises: + RuntimeError: raises runtime error if a not implemented method is called """ # pylint: disable=unused-argument t = "Pillow" @@ -53,9 +59,11 @@ def render_pillow(self, renderer: PillowRenderer) -> None: def render_svg(self, renderer: SvgRenderer) -> None: """Render object using svgwrite - :param renderer: svg renderer - :type renderer: SvgRenderer - :raises RuntimeError: raises runtime error if a not implemented method is called + Parameters: + renderer (SvgRenderer): svg renderer + + Raises: + RuntimeError: raises runtime error if a not implemented method is called """ # pylint: disable=unused-argument t = "SVG" @@ -66,9 +74,11 @@ def render_svg(self, renderer: SvgRenderer) -> None: def render_cairo(self, renderer: CairoRenderer) -> None: """Render object using cairo - :param renderer: cairo renderer - :type renderer: CairoRenderer - :raises RuntimeError: raises runtime error if a not implemented method is called + Parameters: + renderer (CairoRenderer): cairo renderer + + Raises: + RuntimeError: raises runtime error if a not implemented method is called """ # pylint: disable=unused-argument t = "Cairo" @@ -79,13 +89,28 @@ def render_cairo(self, renderer: CairoRenderer) -> None: def pixel_rect(self, trans: Transformer) -> typing.Tuple[float, float, float, float]: """Return the pixel rect (left, top, right, bottom) of the object when using the supplied Transformer. - :param trans: - :type trans: Transformer - :return: pixel rectangle of object - :rtype: typing.Tuple[float, float, float, float] + Parameters: + trans (Transformer): transformer + + Returns: + typing.Tuple[float, float, float, float]: pixel rectangle of object """ bounds = self.bounds() se_x, se_y = trans.ll2pixel(bounds.get_vertex(1)) nw_x, nw_y = trans.ll2pixel(bounds.get_vertex(3)) l, t, r, b = self.extra_pixel_bounds() return nw_x - l, nw_y - t, se_x + r, se_y + b + + def bounds_epb(self, trans: Transformer) -> s2sphere.LatLngRect: + """Return the object bounds including extra pixel bounds of the object when using the supplied Transformer. + + Parameters: + trans (Transformer): transformer + + Returns: + s2sphere.LatLngRect: bounds of object + """ + pixel_bounds = self.pixel_rect(trans) + return s2sphere.LatLngRect.from_point_pair( + trans.pixel2ll(pixel_bounds[0], pixel_bounds[1]), trans.pixel2ll(pixel_bounds[2], pixel_bounds[3]) + ) diff --git a/staticmaps/pillow_renderer.py b/staticmaps/pillow_renderer.py index 7d422f0..7206477 100644 --- a/staticmaps/pillow_renderer.py +++ b/staticmaps/pillow_renderer.py @@ -1,3 +1,5 @@ +"""py-staticmaps PillowRenderer""" + # py-staticmaps # Copyright (c) 2021 Florian Pigorsch; see /LICENSE for licensing information @@ -5,6 +7,7 @@ import math import typing +# import s2sphere # type: ignore from PIL import Image as PIL_Image # type: ignore from PIL import ImageDraw as PIL_ImageDraw # type: ignore @@ -27,24 +30,53 @@ def __init__(self, transformer: Transformer) -> None: self._offset_x = 0 def draw(self) -> PIL_ImageDraw.ImageDraw: + """ + draw Call PIL_ImageDraw.ImageDraw() + + Returns: + PIL_ImageDraw.ImageDraw: An PIL_Image draw object + """ return self._draw def image(self) -> PIL_Image.Image: + """ + image Call PIL_Image.new() + + Returns: + PIL_Image.Image: A PIL_Image image object + """ return self._image def offset_x(self) -> int: + """ + offset_x Return the offset in x direction + + Returns: + int: Offset in x direction + """ return self._offset_x def alpha_compose(self, image: PIL_Image.Image) -> None: + """ + alpha_compose Call PIL_Image.alpha_composite() + + Parameters: + image (PIL_Image.Image): A PIL_Image image object + """ assert image.size == self._image.size self._image = PIL_Image.alpha_composite(self._image, image) self._draw = PIL_ImageDraw.Draw(self._image) - def render_objects(self, objects: typing.List["Object"]) -> None: + def render_objects( + self, + objects: typing.List["Object"], + tighten: bool, + ) -> None: """Render all objects of static map - :param objects: objects of static map - :type objects: typing.List["Object"] + Parameters: + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) for obj in objects: @@ -55,18 +87,25 @@ def render_objects(self, objects: typing.List["Object"]) -> None: def render_background(self, color: typing.Optional[Color]) -> None: """Render background of static map - :param color: background color - :type color: typing.Optional[Color] + Parameters: + color (typing.Optional[Color]): background color """ if color is None: return - self.draw().rectangle([(0, 0), self.image().size], fill=color.int_rgba()) - - def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: - """Render background of static map - - :param download: url of tiles provider - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] + self.draw().rectangle(((0, 0), self.image().size), fill=color.int_rgba()) + + def render_tiles( + self, + download: typing.Callable[[int, int, int], typing.Optional[bytes]], + objects: typing.List["Object"], + tighten: bool, + ) -> None: + """Render tiles of static map + + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): url of tiles provider + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ for yy in range(0, self._trans.tiles_y()): y = self._trans.first_tile_y() + yy @@ -91,8 +130,8 @@ def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optiona def render_attribution(self, attribution: typing.Optional[str]) -> None: """Render attribution from given tiles provider - :param attribution: Attribution for the given tiles provider - :type attribution: typing.Optional[str]: + Parameters: + attribution (typing.Optional[str]:): Attribution for the given tiles provider """ if (attribution is None) or (attribution == ""): return @@ -112,15 +151,13 @@ def fetch_tile( ) -> typing.Optional[PIL_Image.Image]: """Fetch tiles from given tiles provider - :param download: callable - :param x: width - :param y: height - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] - :type x: int - :type y: int + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): callable + x (int): width + y (int): height - :return: pillow image - :rtype: typing.Optional[PIL_Image.Image] + Returns: + typing.Optional[PIL_Image.Image]: pillow image """ image_data = download(self._trans.zoom(), x, y) if image_data is None: @@ -131,10 +168,10 @@ def fetch_tile( def create_image(image_data: bytes) -> PIL_Image.Image: """Create a pillow image - :param image_data: Image data - :type image_data: bytes + Parameters: + image_data (bytes): Image data - :return: pillow image - :rtype: PIL.Image + Returns: + PIL.Image: pillow image """ return PIL_Image.open(io.BytesIO(image_data)).convert("RGBA") diff --git a/staticmaps/renderer.py b/staticmaps/renderer.py index a94177d..84205f9 100644 --- a/staticmaps/renderer.py +++ b/staticmaps/renderer.py @@ -1,13 +1,15 @@ -# py-staticmaps +"""py-staticmaps - renderer""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information -from abc import ABC, abstractmethod import typing +from abc import ABC, abstractmethod + +import s2sphere # type: ignore from .color import Color from .transformer import Transformer - if typing.TYPE_CHECKING: # avoid circlic import from .area import Area # pylint: disable=cyclic-import @@ -26,67 +28,99 @@ def __init__(self, transformer: Transformer) -> None: def transformer(self) -> Transformer: """Return transformer object - :return: transformer - :rtype: Transformer + Returns: + Transformer: transformer """ return self._trans @abstractmethod - def render_objects(self, objects: typing.List["Object"]) -> None: + def render_objects( + self, + objects: typing.List["Object"], + tighten: bool, + ) -> None: """Render all objects of static map - :param objects: objects of static map - :type objects: typing.List["Object"] + Parameters: + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ @abstractmethod def render_background(self, color: typing.Optional[Color]) -> None: """Render background of static map - :param color: background color - :type color: typing.Optional[Color] + Parameters: + color (typing.Optional[Color]): background color """ @abstractmethod - def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: - """Render background of static map - - :param download: url of tiles provider - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] + def render_tiles( + self, + download: typing.Callable[[int, int, int], typing.Optional[bytes]], + objects: typing.List["Object"], + tighten: bool, + ) -> None: + """Render tiles of static map + + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): url of tiles provider + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ def render_marker_object(self, marker: "Marker") -> None: """Render marker object of static map - :param marker: marker object - :type marker: Marker + Parameters: + marker (Marker): marker object """ def render_image_marker_object(self, marker: "ImageMarker") -> None: """Render image marker object of static map - :param marker: image marker object - :type marker: ImageMarker + Parameters: + marker (ImageMarker): image marker object """ def render_line_object(self, line: "Line") -> None: """Render line object of static map - :param line: line object - :type line: Line + Parameters: + line (Line): line object """ def render_area_object(self, area: "Area") -> None: """Render area object of static map - :param area: area object - :type area: Area + Parameters: + area (Area): area object """ @abstractmethod def render_attribution(self, attribution: typing.Optional[str]) -> None: """Render attribution from given tiles provider - :param attribution: Attribution for the given tiles provider - :type attribution: typing.Optional[str]: + Parameters: + attribution (typing.Optional[str]): Attribution for the given tiles provider """ + + def get_object_bounds(self, objects: typing.List["Object"]) -> s2sphere.LatLngRect: + """Return "cumulated" boundaries of all objects + + Parameters: + objects typing.List["Object"]): list of all objects to be rendered in the static map + + Returns: + s2sphere.LatLngRect: LatLngRect object with "cumulated" boundaries of all objects + """ + + new_bounds: typing.Optional[s2sphere.LatLngRect] = None + for obj in objects: + bounds_epb = obj.bounds_epb(self._trans) + if new_bounds: + new_bounds = new_bounds.union(bounds_epb) + else: + new_bounds = bounds_epb + + return new_bounds diff --git a/staticmaps/svg_renderer.py b/staticmaps/svg_renderer.py index 3a4a69f..2c9c3f6 100644 --- a/staticmaps/svg_renderer.py +++ b/staticmaps/svg_renderer.py @@ -1,13 +1,16 @@ +"""py-staticmaps - SvgRenderer""" + # py-staticmaps -# Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information +# Copyright (c) 2022 Florian Pigorsch; see /LICENSE for licensing information import base64 import math import typing +import s2sphere # type: ignore import svgwrite # type: ignore -from .color import Color, BLACK, WHITE +from .color import BLACK, WHITE, Color from .renderer import Renderer from .transformer import Transformer @@ -32,41 +35,47 @@ def __init__(self, transformer: Transformer) -> None: def drawing(self) -> svgwrite.Drawing: """Return the svg drawing for the image - :return: svg drawing - :rtype: svgwrite.Drawing + Returns: + svgwrite.Drawing: svg drawing """ return self._draw def group(self) -> svgwrite.container.Group: """Return the svg group for the image - :return: svg group - :rtype: svgwrite.container.Group + Returns: + svgwrite.container.Group: svg group """ assert self._group is not None return self._group - def render_objects(self, objects: typing.List["Object"]) -> None: + def render_objects( + self, + objects: typing.List["Object"], + tighten: bool, + ) -> None: """Render all objects of static map - :param objects: objects of static map - :type objects: typing.List["Object"] + Parameters: + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ + self._group = self._draw.g(clip_path="url(#page)") x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) for obj in objects: for p in range(-x_count, x_count + 1): - self._group = self._draw.g( - clip_path="url(#page)", transform=f"translate({p * self._trans.world_width()}, 0)" - ) + group = self._draw.g(clip_path="url(#page)", transform=f"translate({p * self._trans.world_width()}, 0)") obj.render_svg(self) - self._draw.add(self._group) - self._group = None + self._group.add(group) + objects_group = self._tighten_to_boundary(self._group, objects, tighten) + self._draw.add(objects_group) + self._group = None def render_background(self, color: typing.Optional[Color]) -> None: """Render background of static map - :param color: background color - :type color: typing.Optional[Color] + Parameters: + color (typing.Optional[Color]): background color """ if color is None: return @@ -74,13 +83,20 @@ def render_background(self, color: typing.Optional[Color]) -> None: group.add(self._draw.rect(insert=(0, 0), size=self._trans.image_size(), rx=None, ry=None, fill=color.hex_rgb())) self._draw.add(group) - def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: - """Render background of static map - - :param download: url of tiles provider - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] + def render_tiles( + self, + download: typing.Callable[[int, int, int], typing.Optional[bytes]], + objects: typing.List["Object"], + tighten: bool, + ) -> None: + """Render tiles of static map + + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): url of tiles provider + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries """ - group = self._draw.g(clip_path="url(#page)") + self._group = self._draw.g(clip_path="url(#page)") for yy in range(0, self._trans.tiles_y()): y = self._trans.first_tile_y() + yy if y < 0 or y >= self._trans.number_of_tiles(): @@ -91,25 +107,68 @@ def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optiona tile_img = self.fetch_tile(download, x, y) if tile_img is None: continue - group.add( + self._group.add( self._draw.image( tile_img, insert=( - xx * self._trans.tile_size() + self._trans.tile_offset_x(), - yy * self._trans.tile_size() + self._trans.tile_offset_y(), + int(xx * self._trans.tile_size() + self._trans.tile_offset_x()), + int(yy * self._trans.tile_size() + self._trans.tile_offset_y()), ), size=(self._trans.tile_size(), self._trans.tile_size()), ) ) except RuntimeError: pass - self._draw.add(group) + tiles_group = self._tighten_to_boundary(self._group, objects, tighten) + self._draw.add(tiles_group) + self._group = None + + def _tighten_to_boundary( + self, group: svgwrite.container.Group, objects: typing.List["Object"], tighten: bool = False + ) -> svgwrite.container.Group: + """Calculate scale and offset for tight rendering on the boundary + + Parameters: + group (svgwrite.container.Group): svg group + objects (typing.List["Object"]): objects of static map + tighten (bool): tighten to boundaries + Returns: + svgwrite.container.Group: svg group + """ + # pylint: disable=too-many-locals + if not tighten: + return group + + # boundary points + bounds = self.get_object_bounds(objects) + nw_x, nw_y = self._trans.ll2pixel(s2sphere.LatLng.from_angles(bounds.lat_lo(), bounds.lng_lo())) + se_x, se_y = self._trans.ll2pixel(s2sphere.LatLng.from_angles(bounds.lat_hi(), bounds.lng_hi())) + # boundary size + size_x = se_x - nw_x + size_y = nw_y - se_y + # scale to boundaries + width = self._trans.image_width() + height = self._trans.image_height() + # scale = 1, if division by zero + try: + scale_x = size_x / width + scale_y = size_y / height + scale = 1 / max(scale_x, scale_y) + except ZeroDivisionError: + scale = 1 + # translate new center to old center + off_x = -0.5 * width * (scale - 1) + off_y = -0.5 * height * (scale - 1) + # finally, translate and scale + group.translate(off_x, off_y) + group.scale(scale) + return group def render_attribution(self, attribution: typing.Optional[str]) -> None: """Render attribution from given tiles provider - :param attribution: Attribution for the given tiles provider - :type attribution: typing.Optional[str]: + Parameters: + attribution (typing.Optional[str]:): Attribution for the given tiles provider """ if (attribution is None) or (attribution == ""): return @@ -140,15 +199,14 @@ def fetch_tile( ) -> typing.Optional[str]: """Fetch tiles from given tiles provider - :param download: callable - :param x: width - :param y: height - :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] - :type x: int - :type y: int + Parameters: + download (typing.Callable[[int, int, int], typing.Optional[bytes]]): + callable + x (int): width + y (int): height - :return: svg drawing - :rtype: typing.Optional[str] + Returns: + typing.Optional[str]: svg drawing """ image_data = download(self._trans.zoom(), x, y) if image_data is None: @@ -159,10 +217,11 @@ def fetch_tile( def guess_image_mime_type(data: bytes) -> str: """Guess mime type from image data - :param data: image data - :type data: bytes - :return: mime type - :rtype: str + Parameters: + data (bytes): image data + + Returns: + str: mime type """ if data[:4] == b"\xff\xd8\xff\xe0" and data[6:11] == b"JFIF\0": return "image/jpeg" @@ -174,11 +233,11 @@ def guess_image_mime_type(data: bytes) -> str: def create_inline_image(image_data: bytes) -> str: """Create an svg inline image - :param image_data: Image data - :type image_data: bytes + Parameters: + image_data (bytes): Image data - :return: svg inline image - :rtype: str + Returns: + str: svg inline image """ image_type = SvgRenderer.guess_image_mime_type(image_data) return f"data:{image_type};base64,{base64.b64encode(image_data).decode('utf-8')}" diff --git a/staticmaps/tile_downloader.py b/staticmaps/tile_downloader.py index 51a55d4..597c7fa 100644 --- a/staticmaps/tile_downloader.py +++ b/staticmaps/tile_downloader.py @@ -1,16 +1,19 @@ -# py-staticmaps +"""py-staticmaps - tile_downloader""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import os import pathlib import typing -import requests +import requests # type: ignore import slugify # type: ignore from .meta import GITHUB_URL, LIB_NAME, VERSION from .tile_provider import TileProvider +REQUEST_TIMEOUT = 10 + class TileDownloader: """A tile downloader class""" @@ -22,27 +25,26 @@ def __init__(self) -> None: def set_user_agent(self, user_agent: str) -> None: """Set the user agent for the downloader - :param user_agent: user agent - :type user_agent: str + Parameters: + user_agent (str): user agent """ self._user_agent = user_agent def get(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) -> typing.Optional[bytes]: """Get tiles - :param provider: tile provider - :type provider: TileProvider - :param cache_dir: cache directory for tiles - :type cache_dir: str - :param zoom: zoom for static map - :type zoom: int - :param x: x value of center for the static map - :type x: int - :param y: y value of center for the static map - :type y: int - :return: tiles - :rtype: typing.Optional[bytes] - :raises RuntimeError: raises a runtime error if the the server response status is not 200 + Parameters: + provider (TileProvider): tile provider + cache_dir (str): cache directory for tiles + zoom (int): zoom for static map + x (int): x value of center for the static map + y (int): y value of center for the static map + + Returns: + typing.Optional[bytes]: tiles + + Raises: + RuntimeError: raises a runtime error if the server response status is not 200 """ file_name = None if cache_dir is not None: @@ -54,7 +56,7 @@ def get(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) url = provider.url(zoom, x, y) if url is None: return None - res = requests.get(url, headers={"user-agent": self._user_agent}, timeout=10) + res = requests.get(url, headers={"user-agent": self._user_agent}, timeout=REQUEST_TIMEOUT) if res.status_code == 200: data = res.content else: @@ -69,10 +71,11 @@ def get(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) def sanitized_name(self, name: str) -> str: """Return sanitized name - :param name: name to sanitize - :type name: str - :return: sanitized name - :rtype: str + Parameters: + name (str): name to sanitize + + Returns: + str: sanitized name """ if name in self._sanitized_name_cache: return self._sanitized_name_cache[name] @@ -85,17 +88,14 @@ def sanitized_name(self, name: str) -> str: def cache_file_name(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) -> str: """Return a cache file name - :param provider: tile provider - :type provider: TileProvider - :param cache_dir: cache directory for tiles - :type cache_dir: str - :param zoom: zoom for static map - :type zoom: int - :param x: x value of center for the static map - :type x: int - :param y: y value of center for the static map - :type y: int - :return: cache file name - :rtype: str + Parameters: + provider (TileProvider): tile provider + cache_dir (str): cache directory for tiles + zoom (int): zoom for static map + x (int): x value of center for the static map + y (int): y value of center for the static map + + Returns: + str: cache file name """ return os.path.join(cache_dir, self.sanitized_name(provider.name()), str(zoom), str(x), f"{y}.png") diff --git a/staticmaps/tile_provider.py b/staticmaps/tile_provider.py index 670fd85..15ad0df 100644 --- a/staticmaps/tile_provider.py +++ b/staticmaps/tile_provider.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - tile_provider""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import string @@ -24,27 +25,42 @@ def __init__( self._attribution = attribution self._max_zoom = max_zoom if ((max_zoom is not None) and (max_zoom <= 20)) else 20 + def __eq__(self, other: object) -> bool: + if not isinstance(other, TileProvider): + # don't attempt to compare against unrelated types + return NotImplemented + + return ( + self._name == other._name + and str(self._url_pattern) == str(other._url_pattern) + and self._shards == other._shards + and self._api_key == other._api_key + and self._attribution == other._attribution + and self._max_zoom == other._max_zoom + ) + def set_api_key(self, key: str) -> None: """Set an api key - :param key: api key - :type key: str + Parameters: + key (str): api key """ self._api_key = key def name(self) -> str: """Return the name of the tile provider - :return: name of tile provider - :rtype: str + Returns: + str: name of tile provider """ return self._name def attribution(self) -> typing.Optional[str]: """Return the attribution of the tile provider - :return: attribution of tile provider if available - :rtype: typing.Optional[str] + Returns: + typing.Optional[str]: attribution of tile provider if + available """ return self._attribution @@ -52,30 +68,29 @@ def attribution(self) -> typing.Optional[str]: def tile_size() -> int: """Return the tile size - :return: tile size - :rtype: int + Returns: + int: tile size """ return 256 def max_zoom(self) -> int: """Return the maximum zoom of the tile provider - :return: maximum zoom - :rtype: int + Returns: + int: maximum zoom """ return self._max_zoom def url(self, zoom: int, x: int, y: int) -> typing.Optional[str]: """Return the url of the tile provider - :param zoom: zoom for static map - :type zoom: int - :param x: x value of center for the static map - :type x: int - :param y: y value of center for the static map - :type y: int - :return: url with zoom, x and y values - :rtype: typing.Optional[str] + Parameters: + zoom (int): zoom for static map + x (int): x value of center for the static map + y (int): y value of center for the static map + + Returns: + typing.Optional[str]: url with zoom, x and y values """ if len(self._url_pattern.template) == 0: return None diff --git a/staticmaps/transformer.py b/staticmaps/transformer.py index 3732bbe..4134064 100644 --- a/staticmaps/transformer.py +++ b/staticmaps/transformer.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - transformer""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import math @@ -39,32 +40,32 @@ def __init__(self, width: int, height: int, zoom: int, center: s2sphere.LatLng, def world_width(self) -> int: """Return the width of the world in pixels depending on tiles provider - :return: width of the world in pixels - :rtype: int + Returns: + int: width of the world in pixels """ return self._number_of_tiles * self._tile_size def image_width(self) -> int: """Return the width of the image in pixels - :return: width of the image in pixels - :rtype: int + Returns: + int: width of the image in pixels """ return self._width def image_height(self) -> int: """Return the height of the image in pixels - :return: height of the image in pixels - :rtype: int + Returns: + int: height of the image in pixels """ return self._height def zoom(self) -> int: """Return the zoom of the static map - :return: zoom of the static map - :rtype: int + Returns: + int: zoom of the static map """ return self._zoom @@ -72,8 +73,8 @@ def zoom(self) -> int: def image_size(self) -> typing.Tuple[int, int]: """Return the size of the image as tuple of width and height - :return: width and height of the image in pixels - :rtype: tuple + Returns: + tuple: width and height of the image in pixels """ return self._width, self._height @@ -81,64 +82,64 @@ def image_size(self) -> typing.Tuple[int, int]: def number_of_tiles(self) -> int: """Return number of tiles of static map - :return: number of tiles - :rtype: int + Returns: + int: number of tiles """ return self._number_of_tiles def first_tile_x(self) -> int: """Return number of first tile in x - :return: number of first tile - :rtype: int + Returns: + int: number of first tile """ return self._first_tile_x def first_tile_y(self) -> int: """Return number of first tile in y - :return: number of first tile - :rtype: int + Returns: + int: number of first tile """ return self._first_tile_y def tiles_x(self) -> int: """Return number of tiles in x - :return: number of tiles - :rtype: int + Returns: + int: number of tiles """ return self._tiles_x def tiles_y(self) -> int: """Return number of tiles in y - :return: number of tiles - :rtype: int + Returns: + int: number of tiles """ return self._tiles_y def tile_offset_x(self) -> float: """Return tile offset in x - :return: tile offset - :rtype: int + Returns: + int: tile offset """ return self._tile_offset_x def tile_offset_y(self) -> float: """Return tile offset in y - :return: tile offset - :rtype: int + Returns: + int: tile offset """ return self._tile_offset_y def tile_size(self) -> int: """Return tile size - :return: tile size - :rtype: int + Returns: + int: tile size """ return self._tile_size @@ -146,10 +147,11 @@ def tile_size(self) -> int: def mercator(latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: """Mercator projection - :param latlng: LatLng object - :type latlng: s2sphere.LatLng - :return: tile values of given LatLng - :rtype: tuple + Parameters: + latlng (s2sphere.LatLng): LatLng object + + Returns: + tuple: tile values of given LatLng """ lat = latlng.lat().radians lng = latlng.lng().radians @@ -159,12 +161,12 @@ def mercator(latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: def mercator_inv(x: float, y: float) -> s2sphere.LatLng: """Inverse Mercator projection - :param x: x value - :type x: float - :param y: x value - :type y: float - :return: LatLng values of given values - :rtype: s2sphere.LatLng + Parameters: + x (float): x value + y (float): x value + + Returns: + s2sphere.LatLng: LatLng values of given values """ x = 2 * math.pi * (x - 0.5) k = math.exp(4 * math.pi * (0.5 - y)) @@ -174,10 +176,11 @@ def mercator_inv(x: float, y: float) -> s2sphere.LatLng: def ll2t(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: """Transform LatLng values into tiles - :param latlng: LatLng object - :type latlng: s2sphere.LatLng - :return: tile values of given LatLng - :rtype: tuple + Parameters: + latlng (s2sphere.LatLng): LatLng object + + Returns: + tuple: tile values of given LatLng """ x, y = self.mercator(latlng) return self._number_of_tiles * x, self._number_of_tiles * y @@ -185,22 +188,23 @@ def ll2t(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: def t2ll(self, x: float, y: float) -> s2sphere.LatLng: """Transform tile values into LatLng values - :param x: x tile - :type x: float - :param y: x tile - :type y: float - :return: LatLng values of given tile values - :rtype: s2sphere.LatLng + Parameters: + x (float): x tile + y (float): x tile + + Returns: + s2sphere.LatLng: LatLng values of given tile values """ return self.mercator_inv(x / self._number_of_tiles, y / self._number_of_tiles) def ll2pixel(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: """Transform LatLng values into pixel values - :param latlng: LatLng object - :type latlng: s2sphere.LatLng - :return: pixel values of given LatLng - :rtype: tuple + Parameters: + latlng (s2sphere.LatLng): LatLng object + + Returns: + tuple: pixel values of given LatLng """ x, y = self.ll2t(latlng) s = self._tile_size @@ -211,12 +215,12 @@ def ll2pixel(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: def pixel2ll(self, x: float, y: float) -> s2sphere.LatLng: """Transform pixel values into LatLng values - :param x: x pixel - :type x: float - :param y: x pixel - :type y: float - :return: LatLng values of given pixel values - :rtype: s2sphere.LatLng + Parameters: + x (float): x pixel + y (float): x pixel + + Returns: + s2sphere.LatLng: LatLng values of given pixel values """ s = self._tile_size x = (x - self._width / 2) / s + self._tile_center_x diff --git a/tests/mock_tile_downloader.py b/tests/mock_tile_downloader.py index c9f6f1c..f9a0470 100644 --- a/tests/mock_tile_downloader.py +++ b/tests/mock_tile_downloader.py @@ -2,6 +2,7 @@ # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import typing + import staticmaps diff --git a/tests/test_color.py b/tests/test_color.py index fc578bb..95fbb41 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -1,30 +1,180 @@ -# py-staticmaps +"""py-staticmaps - Test Color""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information +import math + import pytest # type: ignore + import staticmaps -def test_parse_color() -> None: - bad = [ +@pytest.mark.parametrize( + "rgb", + [ + (0, 0, 0), + (255, 0, 0), + (0, 0, 255), + (0, 0, 0, 0), + (0, 0, 0, 255), + ], +) +def test_text_color_white(rgb: tuple) -> None: + color = staticmaps.Color(*rgb) + assert staticmaps.WHITE == color.text_color() + assert staticmaps.WHITE.int_rgb() == color.text_color().int_rgb() + + +@pytest.mark.parametrize( + "rgb", + [ + (0, 255, 0), + (255, 255, 0), + (0, 255, 255), + (255, 255, 255), + ], +) +def test_text_color_black(rgb: tuple) -> None: + color = staticmaps.Color(*rgb) + assert staticmaps.BLACK == color.text_color() + assert staticmaps.BLACK.int_rgb() == color.text_color().int_rgb() + + +@pytest.mark.parametrize( + "rgb, hex_color", + [ + ((0, 0, 0), "#000000"), + ((255, 0, 0), "#ff0000"), + ((0, 255, 0), "#00ff00"), + ((0, 0, 255), "#0000ff"), + ((255, 255, 0), "#ffff00"), + ((0, 255, 255), "#00ffff"), + ((255, 255, 255), "#ffffff"), + ], +) +def test_hex_rgb(rgb: tuple, hex_color: str) -> None: + color = staticmaps.Color(*rgb) + assert hex_color == color.hex_rgb() + + +@pytest.mark.parametrize( + "rgb", + [ + (0, 0, 0), + (255, 0, 0), + (0, 0, 255), + (0, 255, 0), + (255, 255, 0), + (0, 255, 255), + (255, 255, 255), + ], +) +def test_int_rgb(rgb: tuple) -> None: + color = staticmaps.Color(*rgb) + assert rgb == color.int_rgb() + + +@pytest.mark.parametrize( + "rgb", + [ + (0, 0, 0, 0), + (255, 0, 0, 0), + (0, 0, 255, 255), + (0, 255, 0, 255), + (255, 255, 0, 0), + (0, 255, 255, 0), + (255, 255, 255, 255), + ], +) +def test_int_rgba(rgb: tuple) -> None: + color = staticmaps.Color(*rgb) + assert rgb == color.int_rgba() + + +@pytest.mark.parametrize( + "rgb, float_color", + [ + ((0, 0, 0), (0.0, 0.0, 0.0)), + ((255, 0, 0), (1.0, 0.0, 0.0)), + ((0, 255, 0), (0.0, 1.0, 0.0)), + ((0, 0, 255), (0.0, 0.0, 1.0)), + ((255, 255, 0), (1.0, 1.0, 0.0)), + ((0, 255, 255), (0.0, 1.0, 1.0)), + ((255, 255, 255), (1.0, 1.0, 1.0)), + ], +) +def test_float_rgb(rgb: tuple, float_color: tuple) -> None: + color = staticmaps.Color(*rgb) + assert float_color == color.float_rgb() + + +@pytest.mark.parametrize( + "rgb, float_color", + [ + ((0, 0, 0), (0.0, 0.0, 0.0, 1.0)), + ((0, 0, 0, 0), (0.0, 0.0, 0.0, 0.0)), + ((255, 0, 0, 0), (1.0, 0.0, 0.0, 0.0)), + ((0, 255, 0, 255), (0.0, 1.0, 0.0, 1.0)), + ((0, 0, 255, 255), (0.0, 0.0, 1.0, 1.0)), + ((255, 255, 0, 0), (1.0, 1.0, 0.0, 0.0)), + ((0, 255, 255, 0), (0.0, 1.0, 1.0, 0.0)), + ((255, 255, 255, 255), (1.0, 1.0, 1.0, 1.0)), + ((0, 0, 0), (0.0, 0.0, 0.0, 1.0)), + ((255, 255, 255), (1.0, 1.0, 1.0, 1.0)), + ], +) +def test_float_rgba(rgb: tuple, float_color: tuple) -> None: + color = staticmaps.Color(*rgb) + assert float_color == color.float_rgba() + + +@pytest.mark.parametrize( + "rgb, float_alpha", + [ + ((0, 0, 0, 0), 0.0), + ((255, 255, 255, 255), 1.0), + ((0, 0, 0), 1.0), + ((255, 255, 255), 1.0), + ((255, 255, 255, 100), 0.39215663), + ((255, 255, 255, 200), 0.78431373), + ], +) +def test_float_a(rgb: tuple, float_alpha: float) -> None: + color = staticmaps.Color(*rgb) + assert math.isclose(float_alpha, color.float_a(), rel_tol=0.0001) + + +@pytest.mark.parametrize( + "good", ["0x1a2b3c", "0x1A2B3C", "#1a2b3c", "0x1A2B3C", "0x1A2B3C4D", "black", "RED", "Green", "transparent"] +) +def test_parse_color(good: str) -> None: + staticmaps.parse_color(good) + + +@pytest.mark.parametrize( + "bad", + [ "", "aaa", "midnightblack", "#123", "#12345", "#1234567", - ] - for s in bad: - with pytest.raises(ValueError): - staticmaps.parse_color(s) - - good = ["0x1a2b3c", "0x1A2B3C", "#1a2b3c", "0x1A2B3C", "0x1A2B3C4D", "black", "RED", "Green", "transparent"] - for s in good: - staticmaps.parse_color(s) + ], +) +def test_parse_color_raises_value_error(bad: str) -> None: + with pytest.raises(ValueError): + staticmaps.parse_color(bad) def test_create() -> None: - bad = [ + staticmaps.Color(1, 2, 3) + staticmaps.Color(1, 2, 3, 4) + + +@pytest.mark.parametrize( + "rgb", + [ (-1, 0, 0), (256, 0, 0), (0, -1, 0), @@ -33,10 +183,24 @@ def test_create() -> None: (0, 0, 256), (0, 0, 0, -1), (0, 0, 0, 256), - ] - for rgb in bad: - with pytest.raises(ValueError): - staticmaps.Color(*rgb) + ], +) +def test_create_raises_value_error(rgb: tuple) -> None: + with pytest.raises(ValueError): + staticmaps.Color(*rgb) - staticmaps.Color(1, 2, 3) - staticmaps.Color(1, 2, 3, 4) + +def test_random_color() -> None: + colors = [ + staticmaps.BLACK, + staticmaps.BLUE, + staticmaps.BROWN, + staticmaps.GREEN, + staticmaps.ORANGE, + staticmaps.PURPLE, + staticmaps.RED, + staticmaps.YELLOW, + staticmaps.WHITE, + ] + for _ in [0, 10]: + assert staticmaps.random_color() in colors diff --git a/tests/test_context.py b/tests/test_context.py index 68751a1..9d4aaa3 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,3 +1,5 @@ +"""py-staticmaps - Test Context""" + # py-staticmaps # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information @@ -9,6 +11,81 @@ from .mock_tile_downloader import MockTileDownloader +def test_add_marker_adds_bounds_is_point() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Marker(staticmaps.create_latlng(48, 8))) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(48, 8), staticmaps.create_latlng(48, 8)) + ) + bounds = context.object_bounds() + assert bounds is not None + assert bounds.is_point() + + +def test_add_two_markers_adds_bounds_is_not_point() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Marker(staticmaps.create_latlng(47, 7))) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(47, 7)) + ) + context.add_object(staticmaps.Marker(staticmaps.create_latlng(48, 8))) + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + bounds = context.object_bounds() + assert bounds is not None + assert not bounds.is_point() + + +def test_add_line_adds_bounds_is_rect() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Line([staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + + +def test_add_greater_line_extends_bounds() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Line([staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + context.add_object(staticmaps.Line([staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9)) + ) + + +def test_add_smaller_line_keeps_bounds() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Line([staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + context.add_object(staticmaps.Line([staticmaps.create_latlng(47.5, 7.5), staticmaps.create_latlng(48, 8)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + + def test_bounds() -> None: context = staticmaps.Context() assert context.object_bounds() is None @@ -20,30 +97,61 @@ def test_bounds() -> None: context.add_object(staticmaps.Marker(staticmaps.create_latlng(47, 7))) assert context.object_bounds() is not None - assert context.object_bounds() == s2sphere.LatLngRect( - staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) ) context.add_object(staticmaps.Marker(staticmaps.create_latlng(47.5, 7.5))) assert context.object_bounds() is not None - assert context.object_bounds() == s2sphere.LatLngRect( - staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) ) - context.add_bounds(s2sphere.LatLngRect(staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9))) + context.add_object(staticmaps.Bounds([staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9)])) assert context.object_bounds() is not None - assert context.object_bounds() == s2sphere.LatLngRect( - staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9) + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9)) ) - context.add_bounds(s2sphere.LatLngRect(staticmaps.create_latlng(47.5, 7.5), staticmaps.create_latlng(48, 8))) + +def test_add_greater_custom_bound_extends_bounds() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Marker(staticmaps.create_latlng(47, 7))) + context.add_object(staticmaps.Marker(staticmaps.create_latlng(48, 8))) assert context.object_bounds() is not None - assert context.object_bounds() == s2sphere.LatLngRect( - staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) + + context.add_object(staticmaps.Bounds([staticmaps.create_latlng(49, 7.5), staticmaps.create_latlng(49, 8)])) + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(49, 8)) ) -def test_render_empty() -> None: +def test_add_smaller_custom_bound_keeps_bounds() -> None: + context = staticmaps.Context() + assert context.object_bounds() is None + + context.add_object(staticmaps.Marker(staticmaps.create_latlng(47, 7))) + context.add_object(staticmaps.Marker(staticmaps.create_latlng(48, 8))) + assert context.object_bounds() is not None + + context.add_object(staticmaps.Bounds([staticmaps.create_latlng(47.5, 7.5), staticmaps.create_latlng(48, 8)])) + assert context.object_bounds() is not None + assert context.object_bounds().approx_equals( # type: ignore[union-attr] + s2sphere.LatLngRect(staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8)) + ) + + +def test_set_wrong_zoom_raises_exception() -> None: + context = staticmaps.Context() + with pytest.raises(ValueError): + context.set_zoom(-1) + with pytest.raises(ValueError): + context.set_zoom(31) + + +def test_render_empty_raises_exception() -> None: context = staticmaps.Context() with pytest.raises(RuntimeError): context.render_svg(200, 100) @@ -55,3 +163,19 @@ def test_render_center_zoom() -> None: context.set_center(staticmaps.create_latlng(48, 8)) context.set_zoom(15) context.render_svg(200, 100) + + +def test_render_with_zoom_without_center_raises_exception() -> None: + context = staticmaps.Context() + context.set_tile_downloader(MockTileDownloader()) + context.set_zoom(15) + with pytest.raises(RuntimeError): + context.render_svg(200, 100) + + +def test_render_with_center_without_zoom_sets_zoom_15() -> None: + context = staticmaps.Context() + context.set_tile_downloader(MockTileDownloader()) + context.set_center(staticmaps.create_latlng(48, 8)) + context.render_svg(200, 100) + assert context.determine_center_zoom(200, 100) == (staticmaps.create_latlng(48, 8), 15) diff --git a/tests/test_coordinates.py b/tests/test_coordinates.py index 1793166..bf555bc 100644 --- a/tests/test_coordinates.py +++ b/tests/test_coordinates.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - Test Coordinates""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import pytest # type: ignore @@ -6,8 +7,15 @@ import staticmaps -def test_parse_latlng() -> None: - bad = [ +@pytest.mark.parametrize("good", ["48,8", " 48 , 8 ", "-48,8", "+48,8", "48,-8", "48,+8", "48.123,8.456"]) +def test_parse_latlng(good: str) -> None: + c = staticmaps.parse_latlng(good) + assert c.is_valid() + + +@pytest.mark.parametrize( + "bad", + [ "", "aaa", "12", @@ -19,36 +27,34 @@ def test_parse_latlng() -> None: "-91,8", "48,-181", "48,181", - ] - for s in bad: - with pytest.raises(ValueError): - staticmaps.parse_latlng(s) - - good = ["48,8", " 48 , 8 ", "-48,8", "+48,8", "48,-8", "48,+8", "48.123,8.456"] - for s in good: - c = staticmaps.parse_latlng(s) - assert c.is_valid() - - -def test_parse_latlngs() -> None: - good = [("", 0), ("48,8", 1), ("48,8 47,7", 2), (" 48,8 47,7 ", 2), ("48,7 48,8 47,7", 3)] - for s, expected_len in good: - a = staticmaps.parse_latlngs(s) - assert len(a) == expected_len - - bad = ["xyz", "48,8 xyz", "48,8 48,181"] - for s in bad: - with pytest.raises(ValueError): - staticmaps.parse_latlngs(s) - - -def test_parse_latlngs2rect() -> None: - good = ["48,8 47,7", " 48,8 47,7 "] - for s in good: - r = staticmaps.parse_latlngs2rect(s) - assert r.is_valid() - - bad = ["xyz", "48,8 xyz", "48,8 48,181", "48,7", "48,7 48,8 47,7"] - for s in bad: - with pytest.raises(ValueError): - staticmaps.parse_latlngs2rect(s) + ], +) +def test_parse_latlng_raises_value_error(bad: str) -> None: + with pytest.raises(ValueError): + staticmaps.parse_latlng(bad) + + +@pytest.mark.parametrize( + "good, expected_len", [("", 0), ("48,8", 1), ("48,8 47,7", 2), (" 48,8 47,7 ", 2), ("48,7 48,8 47,7", 3)] +) +def test_parse_latlngs(good: str, expected_len: int) -> None: + a = staticmaps.parse_latlngs(good) + assert len(a) == expected_len + + +@pytest.mark.parametrize("bad", ["xyz", "48,8 xyz", "48,8 48,181"]) +def test_parse_latlngs_raises_value_error(bad: str) -> None: + with pytest.raises(ValueError): + staticmaps.parse_latlngs(bad) + + +@pytest.mark.parametrize("good", ["48,8 47,7", " 48,8 47,7 "]) +def test_parse_latlngs2rect(good: str) -> None: + r = staticmaps.parse_latlngs2rect(good) + assert r.is_valid() + + +@pytest.mark.parametrize("bad", ["xyz", "48,8 xyz", "48,8 48,181", "48,7", "48,7 48,8 47,7"]) +def test_parse_latlngs2rect_raises_value_error(bad: str) -> None: + with pytest.raises(ValueError): + staticmaps.parse_latlngs2rect(bad) diff --git a/tests/test_tile_provider.py b/tests/test_tile_provider.py index d18e285..146e127 100644 --- a/tests/test_tile_provider.py +++ b/tests/test_tile_provider.py @@ -1,4 +1,5 @@ -# py-staticmaps +"""py-staticmaps - Test TileProvider""" + # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information import staticmaps @@ -18,3 +19,21 @@ def test_sharding() -> None: for s in shard_counts: assert (third * 0.9) < s assert s < (third * 1.1) + + +def test_tile_provider_init() -> None: + t1 = staticmaps.tile_provider.tile_provider_JawgLight + t1.set_api_key("0123456789876543210") + + t2 = staticmaps.TileProvider( + "jawg-light", + url_pattern="https://$s.tile.jawg.io/jawg-light/$z/$x/$y.png?access-token=$k", + shards=["a", "b", "c", "d"], + attribution="Maps (C) Jawg Maps (C) OpenStreetMap.org contributors", + max_zoom=20, + api_key="0123456789876543210", + ) + assert t1.name() == t2.name() == "jawg-light" + assert t1.attribution() == t2.attribution() == "Maps (C) Jawg Maps (C) OpenStreetMap.org contributors" + assert t1.tile_size() == t2.tile_size() == 256 + assert t1.max_zoom() == t2.max_zoom() == 20