├── docs ├── requirements.txt ├── api │ ├── draw.rst │ ├── svg.rst │ ├── svgis.rst │ ├── bounding.rst │ ├── style.rst │ └── index.rst ├── _static │ └── germany.png ├── index.rst ├── styles.rst ├── conf.py ├── Makefile └── cli.rst ├── tests ├── fixtures │ ├── test.zip │ ├── test.proj4 │ ├── cook_bounds_4269.json │ ├── issue-8.geojson │ └── chicago_bounds_2790.json ├── Makefile ├── profile.py ├── test_error.py ├── test_projection.py ├── __init__.py ├── test_graticule.py ├── test_utils.py ├── test_projection_draw.py ├── test_transform.py ├── test_map.py ├── test_dom.py ├── test_svg.py ├── test_bounding.py ├── test_draw.py ├── test_cli.py ├── test_svgis.py └── test_style.py ├── .readthedocs.yml ├── src └── svgis │ ├── errors.py │ ├── __init__.py │ ├── utils.py │ ├── dom.py │ ├── graticule.py │ ├── transform.py │ ├── bounding.py │ ├── projection.py │ ├── svg.py │ ├── style.py │ ├── draw.py │ ├── cli.py │ └── svgis.py ├── tox.ini ├── .gitignore ├── .github └── workflows │ └── python.yml ├── Makefile ├── pyproject.toml ├── README.md └── HISTORY.rst /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /docs/api/draw.rst: -------------------------------------------------------------------------------- 1 | draw 2 | ==== 3 | 4 | .. automodule:: svgis.draw 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/svg.rst: -------------------------------------------------------------------------------- 1 | svg 2 | ==== 3 | 4 | .. automodule:: svgis.svg 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/svgis.rst: -------------------------------------------------------------------------------- 1 | SVGIS 2 | ===== 3 | 4 | .. automodule:: svgis.svgis 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/_static/germany.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitnr/svgis/master/docs/_static/germany.png -------------------------------------------------------------------------------- /tests/fixtures/test.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitnr/svgis/master/tests/fixtures/test.zip -------------------------------------------------------------------------------- /docs/api/bounding.rst: -------------------------------------------------------------------------------- 1 | bounding 2 | ======== 3 | 4 | .. automodule:: svgis.bounding 5 | :members: 6 | -------------------------------------------------------------------------------- /tests/fixtures/test.proj4: -------------------------------------------------------------------------------- 1 | +proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs -------------------------------------------------------------------------------- /docs/api/style.rst: -------------------------------------------------------------------------------- 1 | style 2 | =========== 3 | 4 | .. automodule:: svgis.style 5 | :members: 6 | 7 | .. automodule:: svgis.dom 8 | :members: 9 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | version: 3.7 5 | system_packages: true 6 | install: 7 | - requirements: docs/requirements.txt 8 | - method: pip 9 | path: . 10 | extra_requirements: 11 | - docs 12 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | Classes and methods 2 | =================== 3 | 4 | Svgis is primarily built as a command line tool, but it has full 5 | API customization. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | svgis 11 | svg 12 | draw 13 | style 14 | bounding 15 | 16 | -------------------------------------------------------------------------------- /src/svgis/errors.py: -------------------------------------------------------------------------------- 1 | """SVGIS error type(s).""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, Neil Freeman 7 | 8 | 9 | class SvgisError(Exception): 10 | """The SVGIS error type currently does nothing special.""" 11 | -------------------------------------------------------------------------------- /tests/fixtures/cook_bounds_4269.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::4269" } }, 4 | 5 | "features": [ 6 | { "type": "Feature", "properties": { }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -88.254103, 41.479361 ], [ -87.111162, 41.479361 ], [ -87.111162, 42.153992 ], [ -88.254103, 42.153992 ], [ -88.254103, 41.479361 ] ] ] } } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/issue-8.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": { 7 | "id": 1 8 | }, 9 | "geometry": { 10 | "type": "LineString", 11 | "coordinates": [ 12 | [ 13 | -121.27584880154441, 14 | 37.8362927514546, 15 | 6.25716826505959 16 | ], 17 | [ 18 | -121.27580919688194, 19 | 37.83627707427692, 20 | 6.261006620712578 21 | ] 22 | ] 23 | }, 24 | "bbox": null 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/chicago_bounds_2790.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::2790" } }, 4 | 5 | "features": [ 6 | { "type": "Feature", "properties": { "NAME": "CHICAGO" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 332579.603759207879193, 552875.35382270719856 ], [ 367345.344170691911131, 552875.353822704171762 ], [ 367345.344170692435, 594868.974345945403911 ], [ 332579.603759207297117, 594868.974345948314294 ], [ 332579.603759207879193, 552875.35382270719856 ] ] ] } } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015, 2020, Neil Freeman 7 | 8 | [tox] 9 | isolated_build = True 10 | envlist = 11 | py36 12 | py37 13 | py38 14 | pylint 15 | 16 | [testenv] 17 | commands = python -m unittest 18 | 19 | [testenv:pylint] 20 | deps = pylint>=2.5 21 | commands = 22 | python -m pylint src/svgis 23 | python -m pylint -d missing-module-docstring,missing-function-docstring,missing-class-docstring tests 24 | -------------------------------------------------------------------------------- /src/svgis/__init__.py: -------------------------------------------------------------------------------- 1 | """Create SVG drawings from vector geodata files (SHP, geoJSON, etc).""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, Neil Freeman 7 | # pylint: disable=redefined-builtin 8 | from . import bounding, draw, errors, projection, style, svg, svgis, transform 9 | from .svgis import SVGIS, map 10 | 11 | __version__ = '0.5.3' 12 | 13 | __all__ = [ 14 | 'bounding', 15 | 'draw', 16 | 'errors', 17 | 'projection', 18 | 'style', 19 | 'svg', 20 | 'svgis', 21 | 'transform', 22 | ] 23 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | TIGERS = fixtures/cb_2014_us_nation_20m.json fixtures/tl_2015_11_place.json 2 | PROJECTION = +proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs 3 | 4 | .PHONY: fixtures 5 | 6 | fixtures: $(TIGERS) fixtures/test.svg fixtures/zip.svg 7 | 8 | fixtures/zip.svg: fixtures/test.zip $(TIGERS) 9 | svgis draw $(addprefix zip://$ $@ 12 | 13 | fixtures/test.zip: $(TIGERS) 14 | zip -q $@ $^ 15 | 16 | fixtures/test.svg: fixtures/cb_2014_us_nation_20m.json 17 | - svgis draw \ 18 | --viewbox -j '$(PROJECTION)' -f 1000 \ 19 | -c "polygon { fill: blue }" \ 20 | --bounds -124 20.5 -64 49 $< \ 21 | -o $@ 22 | @touch $@ 23 | -------------------------------------------------------------------------------- /tests/profile.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://www.opensource.org/licenses/GNU General Public License v3 (GPLv3)-license 5 | # Copyright (c) 2016, Neil Freeman 6 | try: 7 | from build.lib.svgis import svgis 8 | except ImportError: 9 | from svgis import svgis 10 | 11 | if __name__ == '__main__': 12 | shp = 'tests/fixtures/cb_2014_us_nation_20m.json' 13 | css = 'polygon{fill:green}' 14 | PROJECTION = '+proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs' 15 | BOUNDS = (-124.0, 20.5, -64.0, 49.0) 16 | 17 | svgis.map(shp, style=css, scale=1000, crs=PROJECTION, bounds=BOUNDS, clip=True, inline=True) 18 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2015-16, Neil Freeman 6 | import logging 7 | import unittest 8 | 9 | from svgis import SVGIS, draw, errors 10 | 11 | 12 | class ErrorTestCase(unittest.TestCase): 13 | 14 | feature = {'geometry': {'type': 'Bizarro', 'coordinates': [[(1, 2), (3, 4)], [(7, 8), (9, 10)]]}, 'properties': {}} 15 | 16 | def setUp(self): 17 | logging.getLogger('svgis').setLevel(logging.CRITICAL) 18 | 19 | def testDrawInvalidGeometry(self): 20 | with self.assertRaises(errors.SvgisError): 21 | draw.geometry(self.feature['geometry']) 22 | 23 | def testSvgisDrawInvalidGeometry(self): 24 | a = SVGIS([]).feature(self.feature, [], []) 25 | assert a == u'' 26 | 27 | 28 | if __name__ == '__main__': 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015, Neil Freeman 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | bin/ 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | .eggs 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Sphinx documentation 46 | docs/_build/ 47 | 48 | readme.rst 49 | 50 | *.cpg 51 | *.dbf 52 | *.shp 53 | *.shx 54 | *.prj 55 | *.xml 56 | *.svg 57 | *.zip 58 | *.sublime-project 59 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: svgis 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: [3.8, 3.9, "3.10"] 12 | deps: 13 | - numpy 14 | - clip,simplify 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install GDAL 24 | run: | 25 | sudo apt-get -q update 26 | sudo apt-get -q install -y libgdal-dev 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip flit 31 | flit install --deps production --extras dev,${{ matrix.deps }} 32 | 33 | - name: Run Python tests 34 | run: make test 35 | 36 | - name: Run CLI tests 37 | run: make test-cli 38 | 39 | - name: Coverage report 40 | run: make coverage -o test 41 | - name: show command line help 42 | run: | 43 | svgis -h 44 | svgis draw -h 45 | svgis style -h 46 | svgis scale -h 47 | svgis project -h 48 | svgis bounds -h 49 | -------------------------------------------------------------------------------- /tests/test_projection.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import unittest 7 | 8 | from svgis import projection 9 | from svgis.errors import SvgisError 10 | from svgis.utils import DEFAULT_GEOID 11 | 12 | SHP = 'tests/fixtures/cb_2014_us_nation_20m.json' 13 | 14 | 15 | class ProjectionTestCase(unittest.TestCase): 16 | def testUtm(self): 17 | assert projection.utm_proj4(-21, 42) == '+proj=utm +zone=27 +north +datum=WGS84 +units=m +no_defs' 18 | 19 | assert projection.utm_proj4(-21, -42) == '+proj=utm +zone=27 +south +datum=WGS84 +units=m +no_defs' 20 | 21 | with self.assertRaises(SvgisError): 22 | projection.utm_proj4(-200, 100) 23 | 24 | def testLocalTm(self): 25 | fixture = ( 26 | '+proj=lcc +lon_0=0 +lat_1=0 +lat_2=0 +lat_0=0 ' 27 | '+x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' 28 | ) 29 | self.assertEqual(projection.tm_proj4(0, 0, 0), fixture) 30 | 31 | def testGenerateCRS(self): 32 | bounds = -82.2, 40.1, -78.9, 45.8 33 | a = projection.generateproj4('utm', bounds=bounds, file_crs=DEFAULT_GEOID) 34 | self.assertEqual(a, '+proj=utm +zone=17 +north +datum=WGS84 +units=m +no_defs') 35 | 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://www.opensource.org/licenses/GNU General Public License v3 (GPLv3)-license 5 | # Copyright (c) 2015-16, Neil Freeman 6 | 7 | # pylint: disable=missing-module-docstring,missing-function-docstring,missing-class-docstring 8 | 9 | import logging 10 | 11 | logger = logging.getLogger('svgis') 12 | ch = logging.StreamHandler() 13 | ch.setLevel(logging.DEBUG) 14 | formatter = logging.Formatter('%(message)s') 15 | ch.setFormatter(formatter) 16 | logger.addHandler(ch) 17 | 18 | TEST_SVG = """ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | """ 33 | 34 | TEST_CSS = """ 35 | polygon {fill: orange;} 36 | .test { stroke: green; } 37 | polyline { stroke: blue} 38 | .test, #baz { stroke-width: 2; } 39 | #test ~ #foo { fill: purple; } 40 | #cat polyline { fill: red } 41 | #meow { stroke-opacity: 0.50 } 42 | circle { r: 1; } 43 | """ 44 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. svgis documentation master file, created by 2 | sphinx-quickstart on Tue Jan 26 11:23:58 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to svgis's documentation! 7 | ================================= 8 | 9 | Create SVG drawings from vector geodata files (SHP, geoJSON, etc). 10 | 11 | SVGIS is a command line tool for creating many small maps. It's designed to 12 | fit well with the Unix toolkit, and play nicely in a workflow, e.g. Makefile. 13 | 14 | It also excels at basic maps that can be later elaborated in a drawing program 15 | (e.g. Illustrator). 16 | 17 | With SVGIS, a command like this: 18 | 19 | .. code:: bash 20 | 21 | svgis draw ne_110m_admin_0_countries.shp \ 22 | --bounds -10 30 40 60 \ 23 | --project EPSG:3035 \ 24 | --scale 5000 \ 25 | --id-field name \ 26 | --style ".ne_110m_admin_0_countries {fill: tan;} #Germany { fill: purple }" \ 27 | 28 | 29 | Generates a map like this: 30 | 31 | .. image:: _static/germany.png 32 | 33 | SVGIS is built on top of `GDAL/OGR `_, so it can read just 34 | about any geodata file format you throw at it. 35 | 36 | This documentation assumes some familiarity with 37 | `CSS `_, how map projections 38 | work, assumes you have some geodata at your disposal. If you don't know where to 39 | find geodata, `Natural Earth `_ is a great place to start. 40 | 41 | .. toctree:: 42 | :maxdepth: 2 43 | 44 | cli 45 | styles 46 | api/index 47 | 48 | * :ref:`genindex` 49 | * :ref:`search` 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, Neil Freeman 7 | 8 | PROJECTION = +proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs 9 | QUIET ?= -q 10 | PYTHONFLAGS = -W ignore 11 | 12 | .PHONY: test deploy clean test-cli fixtures 13 | 14 | docs.zip: $(wildcard docs/*.rst docs/*/*.rst) src/svgis/__init__.py 15 | $(MAKE) -C $(/dev/null | head -5 21 | @python $(PYTHONFLAGS) -m cProfile -s tottime $< | \ 22 | grep -E '(svgis|draw|css|projection|svg|cli|clip|convert|errors).py' 23 | 24 | coords = -110.277906 35.450777 -110.000477 35.649030 25 | 26 | test-cli: tests/fixtures/cb_2014_us_nation_20m.json 27 | svgis project -m utm -- $(coords) 28 | svgis project -- $(coords) 29 | svgis project -m local -- $(coords) 30 | svgis graticule -s 1 -- $(coords) | wc -l 31 | svgis bounds $< 32 | -svgis draw -f 1000 -j utm $< | wc -l 33 | svgis bounds $< | \ 34 | xargs -n4 svgis draw -f 1000 -j '$(PROJECTION)' $< -b | \ 35 | svgis style -c 'polygon{fill:green}' | \ 36 | svgis scale -f 10 - | wc 37 | 38 | coverage: | test 39 | coverage report 40 | coverage html 41 | 42 | test: pyproject.toml 43 | coverage run --rcfile=$< -m unittest $(QUIET) 44 | 45 | fixtures: 46 | $(MAKE) -C tests $@ 47 | 48 | format: 49 | black src tests 50 | 51 | deploy: docs.zip | clean 52 | git push 53 | git push --tags 54 | flit publish 55 | 56 | clean: ; rm -rf build dist 57 | -------------------------------------------------------------------------------- /tests/test_graticule.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import unittest 7 | 8 | from svgis import graticule 9 | from svgis.errors import SvgisError 10 | 11 | 12 | class GraticuleTestCase(unittest.TestCase): 13 | def testCRS(self): 14 | g = graticule.graticule((16.34, -34.81, 32.83, -22.09), step=10000, crs_or_method='utm') 15 | a = next(g) 16 | self.assertIsInstance(a, dict) 17 | 18 | def testErr(self): 19 | with self.assertRaises(SvgisError): 20 | g = graticule.graticule((16.34, -34.81, 32.83, -22.09), step=10000, crs_or_method='file') 21 | next(g) 22 | 23 | def test_feature(self): 24 | g = graticule._feature(0, [1, 2, 3]) 25 | self.assertEqual(g['geometry'], {'type': 'LineString', 'coordinates': [1, 2, 3]}) 26 | self.assertEqual(g['type'], 'Feature') 27 | self.assertEqual(g['id'], 0) 28 | 29 | def test_layer(self): 30 | a = graticule.layer([0, 0, 5, 5], 1) 31 | assert isinstance(a, dict) 32 | assert isinstance(a['features'], (list, tuple)) 33 | self.assertIsInstance(a['features'][0]['geometry']['coordinates'], (list, tuple)) 34 | 35 | def testgraticule(self): 36 | g = graticule.graticule((0, 0, 2, 2), 1) 37 | 38 | fixture1 = [(0, i / 2.0) for i in range(9)] 39 | fixture2 = [(1, i / 2.0) for i in range(9)] 40 | 41 | self.assertSequenceEqual(list(next(g).get('geometry').get('coordinates')), fixture1) 42 | self.assertSequenceEqual(list(next(g).get('geometry').get('coordinates')), fixture2) 43 | 44 | 45 | if __name__ == '__main__': 46 | unittest.main() 47 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import unittest 7 | 8 | from svgis import utils 9 | from svgis.errors import SvgisError 10 | 11 | 12 | class UtilsTestCase(unittest.TestCase): 13 | def test_isinf(self): 14 | self.assertTrue(utils.isinf(float('inf'))) 15 | self.assertTrue(utils.isinf(float('-inf'))) 16 | self.assertFalse(utils.isinf(float(10e10))) 17 | 18 | def test_between(self): 19 | self.assertSequenceEqual(list(utils.between(0.0, 10.0, 10)), [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) 20 | 21 | self.assertSequenceEqual(list(utils.between(0.0, 5.0)), [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5]) 22 | 23 | def test_frange(self): 24 | self.assertSequenceEqual(list(utils.frange(1.0, 2.15, 0.25)), [1.0, 1.25, 1.5, 1.75, 2.0]) 25 | self.assertSequenceEqual(list(utils.frange(1.0, 2.15, 0.25, True)), [1.0, 1.25, 1.5, 1.75, 2.0, 2.25]) 26 | 27 | def test_modfloor(self): 28 | self.assertEqual(utils.modfloor(10, 3), 9) 29 | self.assertEqual(utils.modfloor(17, 4), 16) 30 | 31 | def test_modceil(self): 32 | self.assertEqual(utils.modceil(10, 3), 12) 33 | self.assertEqual(utils.modceil(17, 4), 20) 34 | 35 | def test_dedupe(self): 36 | test = [1, 2, 2, 3, 4, 5, 4] 37 | fixture = [1, 2, 3, 4, 5, 4] 38 | self.assertSequenceEqual(list(utils.dedupe(test)), fixture) 39 | 40 | test = [(1, 2), (3, 4), (3, 4), (5, 4), 'M', (3, 4)] 41 | fixture = [(1, 2), (3, 4), (5, 4), 'M', (3, 4)] 42 | 43 | self.assertSequenceEqual(list(utils.dedupe(test)), fixture) 44 | 45 | 46 | if __name__ == '__main__': 47 | unittest.main() 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "svgis" 7 | author = "Neil Freeman" 8 | author-email = "contact@fakeisthenewreal.org" 9 | home-page = "https://github.com/fitnr/svgis" 10 | keywords = "svg, gis, geojson, shapefile, cartography, map, geodata" 11 | classifiers = [ 12 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 13 | "Intended Audience :: Developers", 14 | "Natural Language :: English", 15 | "Operating System :: Unix", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Operating System :: OS Independent", 20 | ] 21 | description-file = "README.md" 22 | requires = [ 23 | "click>=8,<9", 24 | "pyproj>=2.6", 25 | "fiona>=1.8", 26 | "tinycss2>=1.0.2", 27 | "utm>=0.4.0,<1", 28 | "cssselect>=1.1.0", 29 | "lxml>=4.5.0", 30 | ] 31 | 32 | [tool.flit.metadata.requires-extra] 33 | numpy = ["numpy"] 34 | clip = ["shapely>=1.5.7"] 35 | simplify = ["visvalingamwyatt>=0.1.1"] 36 | dev = [ 37 | "coverage[toml]", 38 | "pylint>2.5.0" 39 | ] 40 | doc = [ 41 | "sphinx", 42 | "sphinx-rtd-theme" 43 | ] 44 | 45 | [tool.flit.scripts] 46 | svgis = "svgis.cli:main" 47 | 48 | [tool.coverage.run] 49 | branch = true 50 | source = ["svgis"] 51 | 52 | [tool.coverage.report] 53 | exclude_lines = [ 54 | "pragma: no cover", 55 | "def __repr__", 56 | "raise NotImplementedError", 57 | "if __name__ == .__main__.:", 58 | "except ImportError", 59 | ] 60 | 61 | [tool.black] 62 | line-length = 120 63 | target-version = ["py310"] 64 | include = 'py$' 65 | skip-string-normalization = true 66 | 67 | [tool.pylint.master] 68 | fail-under = "9.5" 69 | 70 | [tool.pylint.basic] 71 | good-names = "f,g,i,j,k,x,y,z" 72 | 73 | [tool.pylint.messages_control] 74 | disable = "invalid-name" 75 | 76 | [tool.pylint.format] 77 | max-line-length = 120 78 | -------------------------------------------------------------------------------- /tests/test_projection_draw.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import logging 7 | import unittest 8 | from os import path 9 | from xml.dom import minidom 10 | 11 | from svgis import svgis 12 | 13 | EPSG3528 = {'init': 'epsg:3528', 'no_defs': True} 14 | # logging.getLogger('svgis').setLevel(logging.DEBUG) 15 | 16 | 17 | class ProjectionDrawTestCase(unittest.TestCase): 18 | 19 | bounds = { 20 | # IL E (ft) 21 | 2790: (347026, 556571, 364500, 592793), 22 | # NAD 83 23 | 4269: (-87.8, 41.7, -87.5, 41.9), 24 | } 25 | 26 | def setUp(self): 27 | self.files = [ 28 | path.join(path.dirname(__file__), 'fixtures', 'chicago_bounds_2790.json'), 29 | path.join(path.dirname(__file__), 'fixtures', 'cook_bounds_4269.json'), 30 | ] 31 | 32 | svgis.STYLE = '' 33 | 34 | def testDrawWithReProjection(self): 35 | s = svgis.SVGIS(self.files, self.bounds[2790], out_crs=EPSG3528, scalar=100) 36 | svg = s.compose() 37 | 38 | self.assertIn('points', svg) 39 | i = svg.index('points') 40 | self.assertIn('points', svg[i:]) 41 | 42 | polygons = minidom.parseString(svg).getElementsByTagName('polygon') 43 | 44 | self.assertEqual(len(polygons), 2) 45 | 46 | self.assertIn('points', dict(polygons[0].attributes.items())) 47 | self.assertIn('points', dict(polygons[1].attributes.items())) 48 | 49 | def testDrawWithReProjectionRepeat(self): 50 | s = svgis.SVGIS(self.files[1], self.bounds[4269], out_crs=EPSG3528, scalar=100) 51 | s.compose() 52 | u = s.compose() 53 | self.assertIn('points', u) 54 | i = u.index('points') 55 | self.assertIn('points', u[i:]) 56 | 57 | def testDrawWithSameProjection(self): 58 | s = svgis.SVGIS(self.files, crs=2790) 59 | a = s.compose() 60 | self.assertIn('points', a) 61 | i = a.index('points') 62 | self.assertIn('points', a[i:]) 63 | 64 | 65 | if __name__ == '__main__': 66 | unittest.main() 67 | -------------------------------------------------------------------------------- /src/svgis/utils.py: -------------------------------------------------------------------------------- 1 | '''Utilities for svgis''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, Neil Freeman 7 | from itertools import groupby 8 | from math import ceil, floor 9 | 10 | # WGS 84 11 | DEFAULT_GEOID = 4326 12 | 13 | 14 | def isinf(x): 15 | """Check if input is infinite.""" 16 | inf = float('inf') 17 | return x in (inf, inf * -1) 18 | 19 | 20 | def between(a, b, count=None): 21 | """Yield points between two floats""" 22 | jump = (b - a) / float(count or 10.0) 23 | 24 | while a < b: 25 | yield a 26 | a += jump 27 | 28 | 29 | def frange(a, b, step, cover=None): 30 | """like range, but for floats.""" 31 | while a < b: 32 | yield a 33 | a += step 34 | 35 | if cover: 36 | yield a 37 | 38 | 39 | def modfloor(inp, mod): 40 | """A floor function that snaps to steps of size mod.""" 41 | i = inp - (inp % mod) 42 | return int(floor(i)) 43 | 44 | 45 | def modceil(inp, mod): 46 | """A ceil function that snaps to steps of size mod.""" 47 | i = inp + mod - (inp % mod) 48 | return int(ceil(i)) 49 | 50 | 51 | def rnd(i, precision=None): 52 | """Optionally round to a given precision.""" 53 | if precision is None: 54 | return i 55 | 56 | return round(i, precision) 57 | 58 | 59 | def dedupe(array): 60 | """Use itertools.groupby to remove duplicates in a list.""" 61 | try: 62 | array = array.tolist() 63 | except AttributeError: 64 | pass 65 | 66 | for g in groupby(array): 67 | yield g[0] 68 | 69 | 70 | def signed_area(coords): 71 | """Return the signed area enclosed by a ring using the linear time 72 | algorithm at http://www.cgafaq.info/wiki/Polygon_Area. A value >= 0 73 | indicates a counter-clockwise oriented ring.""" 74 | try: 75 | xs, ys = tuple(map(list, zip(*coords))) 76 | except ValueError: 77 | # Attempt to handle a z-dimension 78 | xs, ys, _ = tuple(map(list, zip(*coords))) 79 | 80 | xs.append(xs[1]) 81 | ys.append(ys[1]) 82 | return sum(xs[i] * (ys[i + 1] - ys[i - 1]) for i in range(1, len(coords))) / 2.0 83 | 84 | 85 | def clockwise(coords): 86 | """Check if coordinates move in a clockwise direction.""" 87 | return signed_area(coords) < 0 88 | 89 | 90 | def counterclockwise(coords): 91 | """Check if coordinates move in a counterclockwise direction.""" 92 | return signed_area(coords) >= 0 93 | -------------------------------------------------------------------------------- /tests/test_transform.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2015, Neil Freeman 6 | # pylint: disable=unused-import 7 | import functools 8 | import unittest 9 | 10 | from svgis import transform 11 | 12 | try: 13 | import shapely.geometry 14 | 15 | NO_SHAPELY = False 16 | 17 | except ImportError: 18 | NO_SHAPELY = True 19 | try: 20 | import visvalingamwyatt 21 | 22 | VW = True 23 | except ImportError: 24 | VW = False 25 | 26 | 27 | class ClipTestCase(unittest.TestCase): 28 | """Test svgis.transform.clip""" 29 | 30 | def setUp(self): 31 | self.bounds = (1, 1, 9, 9) 32 | self.coords = [[(2, 2), (100, 2), (11, 11), (12, 12), (2, 10), (2, 2)]] 33 | self.fixture = shapely.geometry.Polygon(((2.0, 9.0), (9.0, 9.0), (9.0, 2.0), (2.0, 2.0), (2.0, 9.0))) 34 | self.gen = (x for x in self.coords[0]) 35 | 36 | @unittest.skipIf(NO_SHAPELY, "Shapely not installed") 37 | def testShapely(self): 38 | minx, miny, maxx, maxy = self.bounds 39 | bounds = { 40 | "type": "Polygon", 41 | "coordinates": [[(minx, miny), (minx, maxy), (maxx, maxy), (maxx, miny), (minx, miny)]], 42 | } 43 | coords = {"type": "Polygon", "coordinates": self.coords} 44 | 45 | b = shapely.geometry.shape(bounds) 46 | c = shapely.geometry.shape(coords) 47 | result = b.intersection(c) 48 | self.assertTrue(result.equals(self.fixture), f"{result} == {self.fixture}") 49 | 50 | @unittest.skipIf(NO_SHAPELY, "Shapely not installed") 51 | def testClip(self): 52 | clipped = transform.clip({"type": "Polygon", "coordinates": self.coords}, self.bounds) 53 | result = shapely.geometry.Polygon(clipped['coordinates'][0]) 54 | self.assertTrue(result.equals(self.fixture), f"{result} == {self.fixture}") 55 | 56 | 57 | class SimplifyTestCase(unittest.TestCase): 58 | """Test svgis.transform.simplifier""" 59 | 60 | def testSimplifyNone(self): 61 | a = transform.simplifier(None) 62 | self.assertIsNone(a) 63 | 64 | @unittest.skipIf(not VW, "visvalingamwyatt is not installed") 65 | def testSimplifyTypeVV(self): 66 | c = transform.simplifier(50) 67 | self.assertIsInstance(c, functools.partial) 68 | 69 | @unittest.skipIf(VW, "visvalingamwyatt is installed") 70 | def testSimplifyTypeNoVW(self): 71 | c = transform.simplifier(50) 72 | self.assertIsNone(c) 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /tests/test_map.py: -------------------------------------------------------------------------------- 1 | """Tests on the svgis.map function""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, Neil Freeman 7 | # pylint: disable=duplicate-code 8 | import os 9 | import unittest 10 | import warnings 11 | from xml.dom import minidom 12 | 13 | from svgis import svgis 14 | 15 | warnings.filterwarnings("always") 16 | 17 | 18 | class MapTestCase(unittest.TestCase): 19 | projection = '+proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs' 20 | bounds = (-124.0, 20.5, -64.0, 49.0) 21 | fixture = 'tests/fixtures/test.svg' 22 | shp = 'tests/fixtures/cb_2014_us_nation_20m.json' 23 | css = 'polygon{fill:green}' 24 | 25 | def testMapWithStyle(self): 26 | result = svgis.map(self.shp, style=self.css, scale=1000, crs=self.projection, bounds=self.bounds, clip=None) 27 | self.assertIn(self.css, result) 28 | 29 | style = 'tmp.css' 30 | 31 | with open(style, 'w') as w: 32 | w.write(self.css) 33 | 34 | try: 35 | result = svgis.map(self.shp, style=[style], scale=1000, crs=self.projection, bounds=self.bounds, clip=None) 36 | self.assertIn(self.css, result) 37 | 38 | finally: 39 | os.remove('tmp.css') 40 | 41 | def testMap(self): 42 | # pylint: disable=duplicate-code 43 | a = svgis.map(self.shp, scale=1000, crs=self.projection, bounds=self.bounds, clip=False) 44 | 45 | with open('a.svg', 'w') as A: 46 | A.write(a) 47 | 48 | try: 49 | result = minidom.parseString(a).getElementsByTagName('svg').item(0) 50 | fixture = minidom.parse(self.fixture).getElementsByTagName('svg').item(0) 51 | 52 | result_vb = [float(x) for x in result.attributes.get('viewBox').value.split(',')] 53 | fixture_vb = [float(x) for x in fixture.attributes.get('viewBox').value.split(',')] 54 | 55 | for r, f in zip(result_vb, fixture_vb): 56 | self.assertAlmostEqual(r, f, 5, 'viewbox doesnt match fixture') 57 | finally: 58 | os.remove("a.svg") 59 | 60 | def testMapProjFile(self): 61 | a = svgis.map(self.shp, scale=1000, crs='tests/fixtures/test.proj4', bounds=self.bounds, clip=False) 62 | 63 | result = minidom.parseString(a).getElementsByTagName('svg').item(0) 64 | fixture = minidom.parse(self.fixture).getElementsByTagName('svg').item(0) 65 | 66 | result_vb = [float(x) for x in result.attributes.get('viewBox').value.split(',')] 67 | fixture_vb = [float(x) for x in fixture.attributes.get('viewBox').value.split(',')] 68 | 69 | for r, f in zip(result_vb, fixture_vb): 70 | self.assertAlmostEqual(r, f, 5) 71 | 72 | 73 | if __name__ == '__main__': 74 | unittest.main() 75 | -------------------------------------------------------------------------------- /src/svgis/dom.py: -------------------------------------------------------------------------------- 1 | '''Utilities for manipulating the DOM and applying styles to same''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | # pylint: disable=c-extension-no-member 8 | import logging 9 | import re 10 | 11 | from lxml.cssselect import CSSSelector 12 | 13 | SVG_NS = 'http://www.w3.org/2000/svg' 14 | LOG = logging.getLogger('svgis') 15 | 16 | 17 | def ampencode(value): 18 | """Escape an ampersand that isn't already url encoded""" 19 | return re.sub(r'&(?!amp;)', r'&', str(value)) 20 | 21 | 22 | def serialize_token(token, previous_token=None): 23 | """ 24 | Convert a tinycss2 selector to string in preparation for use with cssselect. 25 | This involves prepending 'svg|' to element selectors. 26 | 27 | Arguments: 28 | token (tinycss2.ast.Node): The selector to serialize. 29 | """ 30 | prev_type = 'whitespace' if previous_token is None else previous_token.type 31 | if token.type == 'ident' and prev_type == 'whitespace': 32 | return 'svg|' + token.serialize() 33 | return token.serialize() 34 | 35 | 36 | def serialize_prelude(rule): 37 | """Convert the selector section of a CSS rule to string.""" 38 | p = (serialize_token(a, b) for a, b in zip(rule.prelude, [None] + rule.prelude[:-1])) 39 | return ''.join(p).strip() 40 | 41 | 42 | def rule_content(rule): 43 | """Except the content from CSS rules.""" 44 | return [token.serialize() for token in rule.content if token.type != 'whitespace'] 45 | 46 | 47 | def apply_rules(doc, rules, nsmap=None): 48 | """ 49 | Apply tinycss2 rules to an etree.Element (only tested on documents created by SVGIS). 50 | 51 | Args: 52 | doc (ElementTree.Element): The svg document to scan. 53 | rules (list): List of tinycss2 Rules to apply. 54 | nsmap (dict): namespace map. Default: ``{ "svg": "http://www.w3.org/2000/svg" }`` 55 | """ 56 | nsmap = nsmap or {'svg': SVG_NS} 57 | for rule in rules: 58 | apply_rule(doc, rule, nsmap) 59 | 60 | return doc 61 | 62 | 63 | def apply_rule(doc, rule, nsmap): 64 | """ 65 | Apply a tinycss2 rule to an etree.Element 66 | Args: 67 | doc (ElementTree.Element): The svg document to scan. 68 | rule (QualifiedRule): tinycss2 rule 69 | nsmap (dict): namespace map 70 | """ 71 | declaration = serialize_prelude(rule) 72 | selector = CSSSelector(declaration, namespaces=nsmap) 73 | 74 | tokens = rule_content(rule) 75 | rvalue = None 76 | if 'r' in tokens: 77 | i = tokens.index('r') 78 | token = rule.content[i] 79 | for token in rule.content[i + 1 :]: 80 | if token.type == 'number': 81 | rvalue = token.serialize() 82 | break 83 | 84 | rule_attrib = ''.join(tokens) 85 | LOG.debug('rule :%s', rule) 86 | 87 | for el in selector(doc): 88 | style_attrib = el.attrib.get('style', '') + ';' 89 | el.attrib['style'] = (style_attrib + rule_attrib).strip('; ') 90 | if rvalue: 91 | el.attrib['r'] = rvalue 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SVGIS 2 | ----- 3 | 4 | Create SVG drawings from vector geodata files (SHP, geojson, etc). 5 | 6 | SVGIS is great for: creating small multiples, combining lots of datasets in a sensible projection, and drawing styled based on classes in the source data. It's perfect for creating base maps for editing in a drawing program, and its CSS-based styling gives great flexibility for styling. 7 | 8 | ``` 9 | svgis draw input.shp -o out.svg 10 | svgis draw south_dakota.shp north_dakota.geojson -o dakota.svg 11 | svgis draw england.shp scotland.shp wales.shp --style gb.css -o great_britain.svg 12 | ```` 13 | 14 | Complete documentation, with more examples and explanation of classes and methods: https://svgis.readthedocs.io/en/stable/ 15 | 16 | ## Install 17 | 18 | Requires [fiona](http://pypi.python.org/pypi/fiona), which in turn requires GDAL. 19 | 20 | See the [GDAL docs](https://gdal.org/download.html#binaries) for install info. 21 | 22 | Then: 23 | ```` 24 | pip install svgis 25 | ```` 26 | 27 | An optional feature of svgis is clipping polygons to a bounding box. This will speed things up if you need to draw only part of a very complex feature. 28 | 29 | ```` 30 | pip install 'svgis[clip]' 31 | ```` 32 | 33 | ## Commands 34 | 35 | The `svgis` command line tool includes several utilities. The most important is `svgis draw`, which draws SVG maps based on input geodata layers. 36 | 37 | Additional commands: 38 | * `svgis bounds`: get the bounding box for a layer in a given projection 39 | * `svgis graticule`: create a graticule (grid) within a given bounds 40 | * `svgis project`: determine what projection `svgis draw` will (optionally) generate for given bounding box 41 | * `svgis scale`: change the scale of an existing SVG 42 | * `svgis style`: add css styles to an existing SVG 43 | 44 | Read the [docs](https://svgis.readthedocs.io/en/stable/) for complete information on these commands and their options. 45 | 46 | ### Examples 47 | 48 | Draw the outline of the contiguous United States, projected in Albers: 49 | ```` 50 | curl -O http://www2.census.gov/geo/tiger/GENZ2014/shp/cb_2014_us_nation_20m.zip 51 | unzip cb_2014_us_nation_20m.zip 52 | svgis draw cb_2014_us_nation_20m.shp --crs EPSG:5070 --scale 1000 --bounds -124 20.5 -64 49 -o us.svg 53 | ```` 54 | 55 | The next two examples use the [Natural Earth](http://naturalearthdata.com) admin-0 data set. 56 | 57 | Draw upper income countries in green, low-income countries in blue: 58 | 59 | ````css 60 | /* style.css */ 61 | .income_grp_5_Low_income { 62 | fill: blue; 63 | } 64 | .income_grp_3_Upper_middle_income { 65 | fill: green 66 | } 67 | .ne_110m_lakes { 68 | fill: #09d; 69 | stroke: none; 70 | } 71 | ```` 72 | ```` 73 | svgis draw --style style.css --class-fields income_grp ne_110m_admin_0_countries.shp ne_110m_lakes.shp -o out.svg 74 | ```` 75 | 76 | Draw national boundaries and lakes in Europe using the [LAEA Europe projection](https://epsg.io/3035), simplifying the output polygons, and coloring Germany in purple. 77 | 78 | ````bash 79 | svgis draw ne_110m_admin_0_countries.shp ne_110m_lakes.shp \ 80 | --crs EPSG:3035 \ 81 | --scale 1000 \ 82 | --simplify 90 \ 83 | --style '.ne_110m_admin_0_countries { fill: tan } #Germany { fill: purple }' \ 84 | --style '.ne_110m_lakes { fill: #09d; stroke: none; }' \ 85 | --id-field name \ 86 | --bounds -10 30 40 65 \ 87 | -o out.svg 88 | ```` 89 | -------------------------------------------------------------------------------- /src/svgis/graticule.py: -------------------------------------------------------------------------------- 1 | '''Draw regular-interval graticules for a coordinate system''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://www.opensource.org/licenses/GNU General Public License v3 (GPLv3)-license 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | import json 8 | from functools import partial 9 | 10 | from pyproj.transformer import Transformer 11 | 12 | from . import bounding, errors, projection, utils 13 | 14 | 15 | def graticule(bounds, step, crs_or_method=None): 16 | """ 17 | Draw graticules. 18 | 19 | Args: 20 | bounds (tuple): In WGS84 coordinates. 21 | step (int): Distance between graticule lines, in the output projection. 22 | crs_or_method (str): A projection specification. 23 | 24 | Returns: 25 | A generator that yields GeoJSON-like dicts of graticule features. 26 | """ 27 | if crs_or_method == 'file': 28 | raise errors.SvgisError("'file' is not a valid option for projecting graticules.") 29 | 30 | if crs_or_method: 31 | out_crs = projection.pick(crs_or_method, bounds=bounds, file_crs=utils.DEFAULT_GEOID) 32 | unproject = Transformer.from_crs(utils.DEFAULT_GEOID, out_crs, skip_equivalent=True, always_xy=True) 33 | bounds = bounding.transform(bounds, transformer=unproject) 34 | 35 | else: 36 | unproject = Transformer.from_crs(4269, 4269, always_xy=True, skip_equivalent=True) 37 | 38 | minx, miny, maxx, maxy = bounds 39 | 40 | minx, miny = utils.modfloor(minx, step), utils.modfloor(miny, step) 41 | maxx, maxy = utils.modceil(maxx, step), utils.modceil(maxy, step) 42 | 43 | frange = partial(utils.frange, cover=True) 44 | 45 | i = 0 46 | for i, X in enumerate(frange(minx, maxx + step, step), 1): 47 | coords = unproject.itransform([(X, y) for y in frange(miny, maxy + step, step / 2.0)]) 48 | yield _feature(i, coords, axis='x', coord=X) 49 | 50 | for i, Y in enumerate(frange(miny, maxy + step, step), i + 1): 51 | coords = unproject.itransform([(x, Y) for x in frange(minx, maxx + step, step / 2.0)]) 52 | yield _feature(i, coords, axis='y', coord=Y) 53 | 54 | 55 | def _feature(i, coords, axis=None, coord=None): 56 | return { 57 | 'geometry': {'type': 'LineString', 'coordinates': list(coords)}, 58 | 'properties': {'axis': axis, 'coord': coord}, 59 | 'type': 'Feature', 60 | 'id': i, 61 | } 62 | 63 | 64 | def layer(bounds, step, crs=None): 65 | """ 66 | Returns a graticule FeatureCollection object over the given bounds. 67 | 68 | Args: 69 | bounds (tuple): minx, miny, maxx, maxy 70 | step (numeric): distance between graticule lines 71 | crs: a CRS object or SRID. 72 | Returns: 73 | dict 74 | """ 75 | return { 76 | "type": "FeatureCollection", 77 | "crs": { 78 | "type": "name", 79 | "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}, 80 | }, 81 | "features": tuple(graticule(bounds, step, crs)), 82 | } 83 | 84 | 85 | def geojson(bounds, step, crs=None): 86 | """ 87 | Create a geojson with graticules of the given bounds. 88 | 89 | Returns: 90 | str 91 | """ 92 | return json.dumps(layer(bounds, step, crs)) 93 | -------------------------------------------------------------------------------- /tests/test_dom.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import unittest 7 | 8 | import tinycss2 9 | 10 | try: 11 | from lxml import etree 12 | except ImportError: 13 | import xml.etree.ElementTree as etree 14 | 15 | from svgis import dom 16 | 17 | from . import TEST_CSS, TEST_SVG 18 | 19 | 20 | class DomTestCase(unittest.TestCase): 21 | 22 | svg = TEST_SVG 23 | css = TEST_CSS 24 | 25 | def setUp(self): 26 | self.rules = tinycss2.parse_stylesheet(self.css, skip_whitespace=True, skip_comments=True) 27 | self.document = etree.fromstring(self.svg) 28 | 29 | def testSerializeToken(self): 30 | rules = tinycss2.parse_stylesheet("polygon{fill:orange}") 31 | token = rules[0].prelude[0] 32 | assert token.type == 'ident' 33 | 34 | assert ('svg|' + token.serialize()) == 'svg|polygon' 35 | 36 | prelude_value = dom.serialize_token(token) 37 | self.assertEqual(prelude_value, 'svg|polygon') 38 | 39 | prelude = dom.serialize_prelude(rules[0]) 40 | self.assertEqual(prelude, 'svg|polygon') 41 | 42 | pretoken = tinycss2.ast.LiteralToken(1, 1, '.') 43 | serialized = dom.serialize_token(rules[0].prelude[0], pretoken) 44 | self.assertEqual(serialized, 'polygon') 45 | 46 | pretoken = tinycss2.ast.LiteralToken(1, 1, '/') 47 | serialized = dom.serialize_token(rules[0].prelude[0], pretoken) 48 | self.assertEqual(serialized, 'polygon') 49 | 50 | def testApplyRule(self): 51 | rules = tinycss2.parse_stylesheet("polygon {fill: orange}", skip_whitespace=True, skip_comments=True) 52 | svg = etree.fromstring( 53 | '' 54 | '' 55 | '' 56 | ) 57 | dom.apply_rules(svg, rules) 58 | text = etree.tostring(svg).decode('ascii') 59 | self.assertIn('orange', text) 60 | 61 | def testApplyIdentRule(self): 62 | dom.apply_rules(self.document, self.rules) 63 | polygon = self.document.xpath('.//svg:polygon', namespaces={'svg': 'http://www.w3.org/2000/svg'})[0] 64 | 65 | self.assertIsNotNone(polygon) 66 | 67 | self.assertIn('fill:orange', polygon.attrib['style']) 68 | 69 | def testApplyHashRule(self): 70 | dom.apply_rules(self.document, self.rules) 71 | polyline = self.document.xpath(".//*[@id='meow']")[0] 72 | self.assertIsNotNone(polyline) 73 | self.assertIn('stroke-opacity:0.50', polyline.attrib.get('style', '')) 74 | 75 | def testChildToken(self): 76 | css = "#foo > #baz { stroke: green }" 77 | rules = tinycss2.parse_stylesheet(css, skip_whitespace=True, skip_comments=True) 78 | dom.apply_rules(self.document, rules) 79 | polyline = self.document.find(".//*[@id='baz']") 80 | self.assertIn('stroke:green', polyline.attrib.get('style', '')) 81 | 82 | def testApplyRadiusRule(self): 83 | css = tinycss2.parse_stylesheet('circle { r: 1 }') 84 | dom.apply_rules(self.document, css) 85 | circ = self.document.find('.//circle', namespaces=self.document.nsmap) 86 | self.assertIsNotNone(circ) 87 | self.assertIn('1', circ.attrib.get('r', '')) 88 | 89 | 90 | if __name__ == '__main__': 91 | unittest.main() 92 | -------------------------------------------------------------------------------- /tests/test_svg.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import unittest 7 | from xml.dom import minidom 8 | 9 | import six 10 | 11 | from svgis import style, svg 12 | 13 | 14 | class SvgTestCase(unittest.TestCase): 15 | def setUp(self): 16 | self.file = 'tests/fixtures/test.svg' 17 | self.newstyle = 'stroke {color:red;}' 18 | 19 | def test_rescale(self): 20 | new = style.rescale(self.file, 100) 21 | 22 | g = minidom.parseString(new).getElementsByTagName('g')[0] 23 | assert 'scale(100)' in g.attributes.get('transform').value 24 | 25 | def test_create(self): 26 | s = svg.drawing((100, 100), []) 27 | self.assertIsInstance(s, six.string_types) 28 | 29 | s = svg.drawing((100, 100), []) 30 | self.assertIsInstance(s, six.string_types) 31 | 32 | s = svg.drawing((100, 100), [], style=self.newstyle) 33 | self.assertIn(self.newstyle, s) 34 | 35 | def testGroup(self): 36 | g = svg.group() 37 | self.assertIsInstance(g, six.string_types) 38 | 39 | g = svg.group(transform="translate(10,10)") 40 | self.assertIn('transform="translate(10,10)"', g) 41 | 42 | g = svg.group(transform="scale(10)") 43 | self.assertIn('transform="scale(10)"', g) 44 | 45 | def testAttribs(self): 46 | args = {'transform': 'translate(10, 10)', 'fill': 'black'} 47 | self.assertIn('transform="translate(10, 10)"', svg.toattribs(**args)) 48 | self.assertIn('fill="black"', svg.toattribs(**args)) 49 | 50 | def testDrawCircle(self): 51 | point = svg.circle((0.0, 0.0), r=2) 52 | self.assertIsInstance(point, six.string_types) 53 | assert 'r="2"' in point 54 | self.assertIn('cy="0.0"', point) 55 | assert 'cx="0.0"' in point 56 | 57 | def testDrawPath(self): 58 | coordinates = [ 59 | (0.0, 0.0), 60 | (10.0, 0.0), 61 | (10.0, 10.0), 62 | (0.0, 10.0), 63 | (0.0, 0.0), 64 | 'M', 65 | (4.0, 4.0), 66 | (4.0, 5.0), 67 | (5.0, 5.0), 68 | (5.0, 4.0), 69 | (4.0, 4.0), 70 | 'Z', 71 | ] 72 | 73 | path = svg.path(coordinates) 74 | self.assertIsInstance(path, six.string_types) 75 | self.assertIn(' M ', path) 76 | self.assertIn('Z"', path) 77 | self.assertIn('10.0,0.0', path) 78 | 79 | def assertPartsIn(self, fixture, test): 80 | for f in fixture: 81 | self.assertIn(f, test) 82 | 83 | def testRect(self): 84 | rect = svg.rect((0, 0), 10, 10) 85 | self.assertIsInstance(rect, type('')) 86 | fix = '' 87 | self.assertPartsIn(fix, rect) 88 | 89 | def testLine(self): 90 | line = svg.line((0, 0), (10, 10)) 91 | assert isinstance(line, type('')) 92 | fix = '' 93 | self.assertPartsIn(fix, line) 94 | 95 | def testText(self): 96 | text = svg.text('hi', (2, 2)) 97 | assert isinstance(text, type('')) 98 | fix = '' 99 | 100 | self.assertPartsIn(fix, text) 101 | 102 | text = svg.text('hi', (2.22222, 2.222222), precision=2) 103 | assert isinstance(text, type('')) 104 | fix = '' 105 | 106 | self.assertPartsIn(fix, text) 107 | 108 | 109 | if __name__ == '__main__': 110 | unittest.main() 111 | -------------------------------------------------------------------------------- /tests/test_bounding.py: -------------------------------------------------------------------------------- 1 | """Tests on bounding box management.""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, Neil Freeman 7 | import unittest 8 | 9 | from svgis import bounding, errors 10 | 11 | 12 | class ConvertTestCase(unittest.TestCase): 13 | def test_updatebounds(self): 14 | bounds1 = (None, 0.1, None, 1.1) 15 | bounds2 = (0.2, 0.2, 1.2, 1.2) 16 | bounds3 = (0.1, 0.3, 1.5, 1.1) 17 | bounds4 = (0.05, 0.4, float('inf'), 1.2) 18 | bounds5 = (0.05, -1 * float('inf'), 1.4, 1.2) 19 | 20 | self.assertSequenceEqual(bounding.update(bounds1, bounds2), (0.2, 0.1, 1.2, 1.2)) 21 | self.assertSequenceEqual(bounding.update(bounds3, bounds2), (0.1, 0.2, 1.5, 1.2)) 22 | self.assertSequenceEqual(bounding.update(bounds3, bounds4), (0.05, 0.3, 1.5, 1.2)) 23 | self.assertSequenceEqual(bounding.update(bounds3, bounds5), (0.05, 0.3, 1.5, 1.2)) 24 | 25 | def testConvertBbox(self): 26 | bounds = (-100, -100, 100, 100) 27 | 28 | self.assertSequenceEqual(bounding.pad(bounds, ext=100), (-200, -200, 200, 200)) 29 | self.assertSequenceEqual(bounding.pad(bounds, ext=10), (-110, -110, 110, 110)) 30 | 31 | def test_bbox_covers(self): 32 | a = (0, 0, 10, 10) 33 | b = (0, 0, 20, 10) 34 | c = (0, 0, 5, 11) 35 | 36 | self.assertFalse(bounding.covers(a, b)) 37 | self.assertTrue(bounding.covers(b, a)) 38 | self.assertFalse(bounding.covers(a, c)) 39 | self.assertFalse(bounding.covers(c, a)) 40 | self.assertTrue(bounding.covers(c, c)) 41 | 42 | def testbounds_to_ring(self): 43 | fixture = [ 44 | (0, 0), 45 | (0, 0.6), 46 | (0, 1.2), 47 | (0, 1.7999999999999998), 48 | (0, 2.4), 49 | (0, 3.0), 50 | (0, 3.6), 51 | (0, 4.2), 52 | (0, 4.8), 53 | (0, 5.3999999999999995), 54 | (0, 5.999999999999999), 55 | (0.6, 6), 56 | (1.2, 6), 57 | (1.7999999999999998, 6), 58 | (2.4, 6), 59 | (3.0, 6), 60 | (3.6, 6), 61 | (4.2, 6), 62 | (4.8, 6), 63 | (5.3999999999999995, 6), 64 | (5.999999999999999, 6), 65 | (6, 5.999999999999999), 66 | (6, 5.3999999999999995), 67 | (6, 4.8), 68 | (6, 4.2), 69 | (6, 3.6), 70 | (6, 3.0), 71 | (6, 2.4), 72 | (6, 1.7999999999999998), 73 | (6, 1.2), 74 | (6, 0.6), 75 | (6, 0), 76 | (5.999999999999999, 0), 77 | (5.3999999999999995, 0), 78 | (4.8, 0), 79 | (4.2, 0), 80 | (3.6, 0), 81 | (3.0, 0), 82 | (2.4, 0), 83 | (1.7999999999999998, 0), 84 | (1.2, 0), 85 | (0.6, 0), 86 | (0, 0), 87 | ] 88 | 89 | r = bounding.ring((0, 0, 6, 6)) 90 | self.assertSequenceEqual(r, fixture) 91 | 92 | def testTransformBounds(self): 93 | bounds = (-74, 42, -73, 43) 94 | 95 | with self.assertRaises(errors.SvgisError): 96 | bounding.transform(bounds, in_crs=4269) 97 | 98 | with self.assertRaises(errors.SvgisError): 99 | bounding.transform(bounds, out_crs=4269) 100 | 101 | a = bounding.transform(bounds, in_crs=4269, out_crs=3102) 102 | 103 | fixture = (43332271.446783714, 15585187.3924282, 44004528.34377961, 16321716.827537995) 104 | for z in zip(a, fixture): 105 | self.assertAlmostEqual(*z, places=5) 106 | 107 | def testCheck(self): 108 | fixture = (1, 1, 2, 2) 109 | result = bounding.check((2, 2, 1, 1)) 110 | self.assertSequenceEqual(result, fixture) 111 | 112 | result = bounding.check((2, 1, 1, 2)) 113 | self.assertSequenceEqual(result, fixture) 114 | 115 | result = bounding.check((1, 2, 2, 1)) 116 | self.assertSequenceEqual(result, fixture) 117 | 118 | self.assertFalse(bounding.check(None)) 119 | self.assertFalse(bounding.check(object)) 120 | self.assertFalse(bounding.check((1, 2, 3))) 121 | self.assertFalse(bounding.check((1, 2, 3, None))) 122 | 123 | 124 | if __name__ == '__main__': 125 | unittest.main() 126 | -------------------------------------------------------------------------------- /src/svgis/transform.py: -------------------------------------------------------------------------------- 1 | '''Clip and simplify geometries''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, 2020, Neil Freeman 7 | from functools import partial 8 | 9 | try: 10 | from shapely.geometry import mapping, shape 11 | from shapely.geos import TopologicalError 12 | except ImportError: 13 | pass 14 | try: 15 | import numpy as np 16 | except ImportError: 17 | pass 18 | try: 19 | import visvalingamwyatt as vw 20 | except ImportError: 21 | pass 22 | 23 | 24 | def clipper(bbox): 25 | """ 26 | Create a clipping function for a given bounding box. 27 | 28 | Args: 29 | bbox (tuple): bounding box 30 | 31 | Returns: 32 | function that will given geometries to input bounding box 33 | """ 34 | minx, miny, maxx, maxy = bbox 35 | bounds = { 36 | "type": "Polygon", 37 | "coordinates": [[(minx, miny), (minx, maxy), (maxx, maxy), (maxx, miny), (minx, miny)]], 38 | } 39 | try: 40 | bbox_shape = shape(bounds) 41 | 42 | def func(geometry): 43 | # This is technically only needed in Py3, but whatever. 44 | try: 45 | clipped = bbox_shape.intersection(shape(geometry)) 46 | except (ValueError, TopologicalError): 47 | return geometry 48 | 49 | return mapping(clipped) 50 | 51 | except NameError: 52 | 53 | def func(geometry): 54 | return geometry 55 | 56 | return func 57 | 58 | 59 | def clip(geometry, bounds): 60 | """ 61 | Clip a geometry to a bounding box. Equivalent to calling clipper(bounds)(geometry). 62 | 63 | Args: 64 | geometry (dict): geometry object 65 | bounds (tuple): bounding box 66 | 67 | Returns: 68 | (dict) geometry 69 | """ 70 | try: 71 | return clipper(bounds)(geometry) 72 | 73 | except NameError: 74 | return geometry 75 | 76 | 77 | def simplifier(ratio): 78 | """ 79 | Create a simplification function, if visvalingamwyatt is available. 80 | Otherwise, return a noop function. 81 | 82 | Args: 83 | ratio (int): Between 1 and 99 84 | 85 | Returns: 86 | simplification function 87 | """ 88 | try: 89 | # put this first to get NameError out of the way 90 | simplify = vw.simplify_geometry 91 | 92 | if ratio is None or ratio >= 100 or ratio < 1: 93 | raise SvgisError("Invalid ratio") 94 | 95 | return partial(simplify, ratio=ratio / 100.0) 96 | 97 | except (TypeError, ValueError, NameError): 98 | return None 99 | 100 | 101 | def scale(coordinates, scalar=1): 102 | '''Scale a list of coordinates by a scalar. Only use with projected coordinates''' 103 | try: 104 | try: 105 | arr = np.array(coordinates, dtype=float) 106 | 107 | except TypeError: 108 | arr = np.array(list(coordinates), dtype=float) 109 | 110 | return arr * scalar 111 | 112 | except NameError: 113 | if isinstance(coordinates, tuple): 114 | return [coordinates[0] * scalar, coordinates[1] * scalar] 115 | 116 | return [(c[0] * scalar, c[1] * scalar) for c in coordinates] 117 | 118 | 119 | def scale_rings(rings, factor=1): 120 | """Apply scale() to a list of rings.""" 121 | return [scale(ring, factor) for ring in rings] 122 | 123 | 124 | def scale_geom(geom, factor=1): 125 | """ 126 | Scale a geometry by a given factor 127 | 128 | Args: 129 | geom (dict): geojson-like dict 130 | factor (numeric): scale factor, default: 1 131 | """ 132 | if geom['type'] == 'MultiPolygon': 133 | geom['coordinates'] = [scale_rings(rings, factor) for rings in geom['coordinates']] 134 | 135 | elif geom['type'] in ('Polygon', 'MultiLineString'): 136 | geom['coordinates'] = scale_rings(geom['coordinates'], factor) 137 | 138 | elif geom['type'] in ('MultiPoint', 'LineString'): 139 | geom['coordinates'] = scale(geom['coordinates'], factor) 140 | 141 | elif geom['type'] == 'Point': 142 | geom['coordinates'] = scale(geom['coordinates'], factor) 143 | 144 | elif geom['type'] == 'GeometryCollection': 145 | geom['geometries'] = [scale_geom(i) for i in geom['geometries']] 146 | 147 | else: 148 | raise NotImplementedError(f"Unsupported geometry type: {geom['type']}") 149 | 150 | return geom 151 | -------------------------------------------------------------------------------- /src/svgis/bounding.py: -------------------------------------------------------------------------------- 1 | '''Utilities for working with bounding boxes''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, 2020, Neil Freeman 7 | from pyproj.transformer import Transformer 8 | 9 | from . import errors, utils 10 | 11 | 12 | def check(bounds): 13 | '''Check if bounds are valid.''' 14 | # Refuse to set these more than once 15 | try: 16 | if bounds is None or len(bounds) != 4 or not all(bounds): 17 | return False 18 | 19 | except (TypeError, AttributeError, ValueError): 20 | return False 21 | 22 | if bounds[0] > bounds[2]: 23 | bounds = bounds[2], bounds[1], bounds[0], bounds[3] 24 | 25 | if bounds[1] > bounds[3]: 26 | bounds = bounds[0], bounds[3], bounds[2], bounds[1] 27 | 28 | return bounds 29 | 30 | 31 | def update(old, new): 32 | """ 33 | Extend old with any more distant values from newpoints. 34 | Also replace any missing min/max points in old with values from new. 35 | """ 36 | bounds = [] 37 | inf = float('inf') 38 | neginf = inf * -1 39 | 40 | # python3 gives TypeError when using None in min/max 41 | # This contraption avoids that problem. 42 | # List comp below replaces Nones in bounds with real values in new or old 43 | for n, m in zip(new[:2], old[:2]): 44 | try: 45 | if neginf in (m, n): 46 | bounds.append(max(n, m)) 47 | continue 48 | 49 | bounds.append(min(n, m)) 50 | except TypeError: 51 | bounds.append(None) 52 | 53 | for n, m in zip(new[2:], old[2:]): 54 | try: 55 | if inf in (m, n): 56 | bounds.append(min(n, m)) 57 | continue 58 | 59 | bounds.append(max(n, m)) 60 | except TypeError: 61 | bounds.append(None) 62 | 63 | if any(not v for v in bounds): 64 | bounds = list((a or b or c) for a, b, c in zip(bounds, new, old)) 65 | 66 | return bounds 67 | 68 | 69 | def pad(bounds, ext=100): 70 | """ 71 | Pad a bounding box. Works best when input is in a projected unit (i.e. feet or meters). 72 | 73 | Args: 74 | bounds (tuple): a bounding box (minx, miny, maxx, maxy) 75 | ext (int): the distance to pad the box, in projected units. Default: 100. 76 | 77 | Returns: 78 | tuple: A bounding box (minx, miny, maxx, maxy) 79 | """ 80 | try: 81 | return bounds[0] - ext, bounds[1] - ext, bounds[2] + ext, bounds[3] + ext 82 | except TypeError: 83 | return bounds 84 | 85 | 86 | def ring(bounds): 87 | ''' 88 | Convert min, max points to a boundary ring. 89 | 90 | Args: 91 | bounds (tuple): a bounding box (minx, miny, maxx, maxy) 92 | 93 | Return: 94 | list: a geojson-like ring of coordinates 95 | 96 | ''' 97 | minx, miny, maxx, maxy = bounds 98 | xs, ys = list(utils.between(minx, maxx)), list(utils.between(miny, maxy)) 99 | 100 | left_top = [(minx, y) for y in ys] + [(x, maxy) for x in xs][1:] 101 | 102 | ys.reverse() 103 | xs.reverse() 104 | 105 | return left_top + [(maxx, y) for y in ys] + [(x, miny) for x in xs] 106 | 107 | 108 | def covers(b1, b2): 109 | """Check if a bounding box covers another. 110 | 111 | Args: 112 | b1 (tuple): a bounding box (minx, miny, maxx, maxy) 113 | b2 (tuple): a bounding box 114 | 115 | Returns: 116 | bool: ``False`` if any points in ``b2`` are outside ``b1``. 117 | """ 118 | return b1[0] <= b2[0] and b1[1] <= b2[1] and b1[2] >= b2[2] and b1[3] >= b2[3] 119 | 120 | 121 | def transform(bounds, **kwargs): 122 | """ 123 | Project a bounding box, taking care to not slice off the sides. 124 | 125 | Args: 126 | bounds (tuple): bounding box to transform. 127 | transformer (pyproj.transformer.Transformer): A pyproj Transformer instance. 128 | in_crs (dict): Fiona-type proj4 mapping representing input projection. 129 | out_crs (dict): Fiona-type proj4 mapping representing output projection. 130 | 131 | Returns: 132 | tuple: The input ``bounds`` reprojected from ``in_crs`` to ``out_crs`` 133 | """ 134 | transformer = kwargs.get('transformer') 135 | in_crs = kwargs.get('in_crs') 136 | out_crs = kwargs.get('out_crs') 137 | 138 | if not transformer and not (in_crs and out_crs): 139 | raise errors.SvgisError('Need input CRS and output CRS or a Transformer') 140 | 141 | if transformer is None: 142 | transformer = Transformer.from_crs(in_crs, out_crs, always_xy=True) 143 | 144 | densebounds = ring(bounds) 145 | xbounds, ybounds = list(zip(*transformer.itransform(densebounds))) 146 | return min(xbounds), min(ybounds), max(xbounds), max(ybounds) 147 | -------------------------------------------------------------------------------- /docs/styles.rst: -------------------------------------------------------------------------------- 1 | Styling maps 2 | ============ 3 | 4 | You can use any kind of CSS that you like to style maps 5 | made with SVGIS. However, when using the ``inline`` option, 6 | SVGIS supports a subset of CSS. 7 | 8 | This applies both to the selectors, which are limited by 9 | Python's built-in XML support, and to the declarations, 10 | which are limited by the 11 | `styling rules for SVG `_. 12 | 13 | A few useful things to know about how SVGIS draws maps: 14 | 15 | * SVGIS places all the features in a layer in a group. This group has an ``id`` equal to the layer's name, and a ``class`` equal to the column names of the layer. 16 | * The ``class-fields`` and ``id-field`` options can be used to add a ``class`` and ``id`` to the the drawing's elements. SVGIS always adds the layer name as a class to each feature. 17 | * Polygons with holes are drawn as ``path`` elements with the class ``polygon``. 18 | * SVGIS can set the id and class of features based on the input data. 19 | * By default, SVGIS draws black lines and no fill on shapes. 20 | 21 | 22 | Style the features in a layer 23 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 24 | 25 | This works because every element of layer ``cb_2014_us_nation_20m.shp`` will have 26 | the class ``cb_2014_us_nation_20m``. 27 | 28 | .. code:: css 29 | 30 | .cb_2014_us_nation_20m { 31 | fill: blue; 32 | stroke: green; 33 | stroke-width: 1px; 34 | stroke-dasharray: 5, 3, 2; 35 | } 36 | 37 | 38 | Style certain layers 39 | ^^^^^^^^^^^^^^^^^^^^^ 40 | 41 | Say that we're combining geodata from the US Census with data from Natural 42 | Earth. All Census layers have a GEOID field, and we use this to draw these 43 | layers with a ``opacity: 0.50``. 44 | 45 | .. code:: css 46 | 47 | /* example.css */ 48 | .GEOID * { 49 | opacity: 0.50; 50 | } 51 | .tl_2015_us_aiannh { 52 | fill: orange; 53 | } 54 | .ne_10m_time_zones { 55 | stroke-width: 2px; 56 | } 57 | 58 | 59 | Use this style to create a map projected in 60 | `North America Equidistant Conic `_. 61 | 62 | .. code:: bash 63 | 64 | svgis draw --style example.css \ 65 | --project EPSG:102010 \ 66 | tl_2015_us_state.shp \ 67 | tl_2015_us_aiannh.shp \ 68 | ne_10m_time_zones.shp \ 69 | -o out.svg 70 | 71 | 72 | 73 | Style all polygons in a drawing 74 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 75 | 76 | Polygons with holes are drawn as paths, and multipolygons are drawn in groups. 77 | To style all polygons, use the ``.polygon`` class: 78 | 79 | .. code:: css 80 | 81 | polygon, 82 | .polygon { 83 | fill: orange; 84 | stroke: black; 85 | stroke-opacity: 0.8; 86 | } 87 | 88 | 89 | Styling with data 90 | ^^^^^^^^^^^^^^^^^ 91 | 92 | The SVGIS ``class-fields`` and ``id-field`` options can be used to add ``class`` 93 | and ``id`` attributes to the output SVG. These, in turn, can be used to style 94 | the map based on the data. 95 | 96 | Note that the SVGIS has to do minor clean up on the data. Whitespace is replaced 97 | with underscores, and periods, numbers signs and double-quote characters (``.#"``) 98 | are removed. Null values are represented with the Pythonic "None". 99 | 100 | Additionally, CSS classes and IDs technically must begin with ascii letters, 101 | underscores or dashes. Classes and IDs that begin with other characters (after 102 | stripping illegal characters) are prefixed with an underscore (``_``). 103 | 104 | Style a specific element 105 | ~~~~~~~~~~~~~~~~~~~~~~~~ 106 | 107 | To style just Germany in the `Natural Earth `_ 108 | countries layer, use the ``id-field`` option to set the ID of all 109 | features to their ``name_long``. This example also includes lakes. Because 110 | lakes don't have a ``name_long`` atribute, the individual polygons won't 111 | have an ID field. 112 | 113 | .. code:: bash 114 | 115 | svgis draw --style purple.css \ 116 | --id-field name_long \ 117 | ne_110m_admin_0_countries.shp \ 118 | ne_110m_lakes.shp \ 119 | -o out.svg 120 | 121 | .. code:: css 122 | 123 | /* purple.css */ 124 | #Germany { 125 | fill: purple; 126 | } 127 | 128 | #ne_110m_admin_0_countries polygon, 129 | #ne_110m_admin_0_countries .polygon { 130 | fill: tan; 131 | } 132 | 133 | #ne_110m_lakes polygon, 134 | #ne_110m_lakes .polygon { 135 | fill: blue; 136 | } 137 | 138 | 139 | Styling groups of elements 140 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 141 | 142 | Use the ``class-fields`` option to add classes to data based on their data. 143 | In this example, the ``income_grp`` field in the admin-0 data set it used. 144 | This is ideal of SVGIS, since the data is already broken into bins. These bins 145 | have names like "5. Low Income", which SVGIS is sanitized to 146 | ``5_Low_Income``. 147 | 148 | .. code:: css 149 | 150 | /* style.css */ 151 | .income_grp_5_Low_income { 152 | fill: blue; 153 | } 154 | .income_grp_3_Upper_middle_income { 155 | fill: green; 156 | } 157 | 158 | .. code:: bash 159 | 160 | svgis draw --style style.css \ 161 | --class-fields income_grp \ 162 | ne_110m_admin_0_countries.shp \ 163 | -o out.svg 164 | -------------------------------------------------------------------------------- /src/svgis/projection.py: -------------------------------------------------------------------------------- 1 | """Do the work of picking, generating and transforming coordinate reference systems.""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | import logging 8 | import os.path 9 | 10 | import utm 11 | from pyproj.crs import CRS 12 | from pyproj.exceptions import CRSError 13 | 14 | from . import bounding, errors 15 | from .utils import DEFAULT_GEOID 16 | 17 | LOG = logging.getLogger('svgis') 18 | METHODS = 'default', 'file', 'local', 'utm' 19 | 20 | 21 | def tm_proj4(x0, y0, y1): 22 | """ 23 | Generate the proj4 string for a local Transverse Mercator projection 24 | centered at a given longitude and between two latitudes. 25 | 26 | Args: 27 | x0 (float): longitude 28 | y0 (float): latitude 0 29 | y1 (float): latitude 1 30 | 31 | Returns: 32 | (str) proj4 string 33 | """ 34 | proj = f'+proj=lcc +lon_0={x0} +lat_1={y1} +lat_2={y0} +lat_0={y1}' 35 | return proj + ' +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' 36 | 37 | 38 | def utm_proj4(lon, lat): 39 | """ 40 | Generate the proj4 string for the UTM projection at a given (lon, lat) coordinate. 41 | 42 | Args: 43 | lon (float): longitude 44 | lat (float): latitude 45 | 46 | Returns: 47 | (str) proj4 string 48 | """ 49 | try: 50 | _, _, zonenumber, zoneletter = utm.from_latlon(lat, lon) 51 | 52 | if zoneletter in 'ZYXWVUTSRQPN': 53 | hemisphere = 'north' 54 | 55 | elif zoneletter in 'MLKJHGFEDCBA': 56 | hemisphere = 'south' 57 | 58 | return f'+proj=utm +zone={zonenumber} +{hemisphere} +datum=WGS84 +units=m +no_defs' 59 | 60 | except utm.error.OutOfRangeError as err: 61 | raise errors.SvgisError(err) from err 62 | 63 | 64 | def generateproj4(method, bounds, file_crs): 65 | """ 66 | Generate a Proj4 projection definition: either the local UTM zone 67 | or a custom transverse mercator. 68 | 69 | Args: 70 | method (str): If default, local or utm. 71 | * If 'utm': generate a UTM. Otherwise, a local projection, 72 | * If 'local': generate a custom local transverse mercator projection, 73 | * If 'default': if file_crs is longlat, act like local. otherwise use file_crs 74 | bounds (tuple): bounding box 75 | file_crs (dict): Fiona-generated CRS of the input file 76 | 77 | Returns: 78 | (str) proj4 string 79 | """ 80 | LOG.debug('generating proj4') 81 | if bounds is None or file_crs is None: 82 | raise errors.SvgisError('generatecrs missing bounds and file crs') 83 | 84 | is_longlat = _is_longlat(file_crs) 85 | if method == 'default': 86 | # Check if file_crs _is_longlat, if not use that. 87 | # Option to continue returning default if we didn't get a file_crs 88 | if is_longlat: 89 | method = 'local' 90 | else: 91 | return CRS(file_crs) 92 | 93 | if is_longlat: 94 | longlat_bounds = bounds 95 | else: 96 | longlat_bounds = bounding.transform(bounds, in_crs=file_crs, out_crs=DEFAULT_GEOID) 97 | 98 | minx, miny, maxx, maxy = longlat_bounds 99 | 100 | if method == 'utm': 101 | midx = (minx + maxx) / 2 102 | midy = (miny + maxy) / 2 103 | return utm_proj4(midx, midy) 104 | 105 | if method == 'local': 106 | # Create a custom TM projection 107 | x0 = (float(minx) + float(maxx)) // 2 108 | 109 | return tm_proj4(x0, miny, maxy) 110 | 111 | raise errors.SvgisError(f"Unexpected method. Valid methods are default, local or utm. Got: {method}") 112 | 113 | 114 | def _is_longlat(crs): 115 | '''Test if CRS is in lat/long coordinates''' 116 | try: 117 | return crs['proj'] == 'longlat' 118 | except (KeyError, TypeError, AttributeError): 119 | pass 120 | 121 | projection = CRS(crs) 122 | 123 | try: 124 | return projection.is_geographic 125 | except AttributeError: 126 | return crs.to_dict().get('proj') == 'longlat' 127 | 128 | 129 | def pick(project, bounds=None, file_crs=None): 130 | """ 131 | Pick a projection or projection method to use. 132 | 133 | Returns: 134 | (mixed) one of: None, 'local', 'utm' or a dict 135 | """ 136 | LOG.debug('projection.pick("%s")', project) 137 | project = project or 'default' 138 | if isinstance(project, CRS): 139 | return project 140 | 141 | try: 142 | return CRS(project) 143 | except CRSError: 144 | pass 145 | 146 | if isinstance(project, str): 147 | LOG.debug('projection is a str') 148 | if project.lower() == 'file': 149 | LOG.debug('"project" == file') 150 | return file_crs if file_crs is not None else 'file' 151 | 152 | if project.lower() in METHODS: 153 | LOG.debug('"project" is in METHODS') 154 | return CRS(generateproj4(project, bounds, file_crs)) 155 | 156 | if os.path.exists(project): 157 | with open(project) as f: 158 | return CRS(f.read()) 159 | 160 | raise errors.SvgisError(f'Unable to convert to projection: {project}') 161 | 162 | 163 | def fake_to_string(crs): 164 | """ 165 | Fake to_string for debugging in places where fiona.crs.to_string 166 | isn't available 167 | """ 168 | return ' '.join(f'+{i[0]}={i[1]}' for i in crs.items()) 169 | -------------------------------------------------------------------------------- /tests/test_draw.py: -------------------------------------------------------------------------------- 1 | # This file is part of svgis. 2 | # https://github.com/fitnr/svgis 3 | # Licensed under the GNU General Public License v3 (GPLv3) license: 4 | # http://opensource.org/licenses/GPL-3.0 5 | # Copyright (c) 2016, Neil Freeman 6 | import re 7 | import unittest 8 | 9 | import six 10 | 11 | from svgis import draw, errors, svgis 12 | 13 | 14 | class DrawTestCase(unittest.TestCase): 15 | 16 | properties = {'cat': u'meow', 'dog': 'woof'} 17 | 18 | classes = [u'foo', 'cat'] 19 | 20 | lis1 = [ 21 | [-110.6, 35.3], 22 | [-110.7, 35.5], 23 | [-110.3, 35.5], 24 | [-110.2, 35.1], 25 | [-110.2, 35.8], 26 | [-110.3, 35.2], 27 | [-110.1, 35.8], 28 | [-110.8, 35.5], 29 | [-110.7, 35.7], 30 | [-110.1, 35.4], 31 | [-110.7, 35.1], 32 | [-110.6, 35.3], 33 | ] 34 | 35 | lis2 = [ 36 | [-110.8, 35.3], 37 | [-110.6, 35.4], 38 | [-110.1, 35.5], 39 | [-110.1, 35.5], 40 | [-110.4, 35.2], 41 | [-110.5, 35.1], 42 | [-110.5, 35.1], 43 | [-110.9, 35.8], 44 | [-110.5, 35.1], 45 | [-110.8, 35.3], 46 | ] 47 | 48 | def setUp(self): 49 | self.multipolygon = { 50 | "properties": self.properties, 51 | "geometry": {"type": "MultiPolygon", "id": "MultiPolygon", "coordinates": [[self.lis1], [self.lis2]]}, 52 | } 53 | self.polygon = { 54 | "properties": self.properties, 55 | "geometry": {"type": "Polygon", "id": "Polygon", "coordinates": [self.lis1]}, 56 | } 57 | self.multilinestring = { 58 | "properties": self.properties, 59 | "geometry": {'type': 'MultiLineString', "id": "MultiLineString", 'coordinates': [self.lis2, self.lis2]}, 60 | } 61 | self.linestring = { 62 | "properties": self.properties, 63 | "geometry": { 64 | 'coordinates': self.lis2, 65 | 'type': 'LineString', 66 | "id": "LineString", 67 | }, 68 | } 69 | self.point = { 70 | "properties": self.properties, 71 | "geometry": { 72 | 'coordinates': (0.0, 0), 73 | 'type': 'Point', 74 | "id": "Point", 75 | }, 76 | } 77 | 78 | self.obj = svgis.SVGIS([]) 79 | 80 | def testDrawPoint(self): 81 | feat = self.obj.feature(self.point, [], classes=self.classes, id_field=None) 82 | 83 | assert isinstance(feat, six.string_types) 84 | self.assertIn('cat_meow', feat) 85 | 86 | def testDrawLine(self): 87 | line = draw.lines(self.linestring['geometry']) 88 | assert isinstance(line, six.string_types) 89 | 90 | feat = self.obj.feature(self.linestring, [], classes=self.classes, id_field=None) 91 | 92 | assert isinstance(feat, six.string_types) 93 | assert 'cat_meow' in feat 94 | 95 | def testDrawMultiLine(self): 96 | mls1 = draw.multilinestring(self.multilinestring['geometry']['coordinates']) 97 | mls2 = draw.lines(self.multilinestring['geometry']) 98 | 99 | assert isinstance(mls1, six.string_types) 100 | assert isinstance(mls2, six.string_types) 101 | 102 | grp = self.obj.feature(self.multilinestring, [], classes=self.classes, id_field=None) 103 | 104 | assert isinstance(grp, six.string_types) 105 | assert 'cat_meow' in grp 106 | 107 | def testDrawPolygon(self): 108 | drawn = draw.polygon(self.polygon['geometry']['coordinates']) 109 | assert "{},{}".format(*self.lis1[0]) in drawn 110 | feat = self.obj.feature(self.polygon, [], classes=self.classes, id_field=None) 111 | assert 'fill-rule="evenodd"' in feat 112 | assert 'cat_meow' in feat 113 | 114 | def testDrawMultiPolygon(self): 115 | drawn = draw.multipolygon(self.multipolygon['geometry']['coordinates']) 116 | 117 | assert isinstance(drawn, six.string_types) 118 | 119 | def testDrawMultiPoint(self): 120 | points = draw.multipoint(self.lis1, id='foo') 121 | 122 | self.assertIn('cy="35.1"', points) 123 | self.assertIn('cx="-110.6"', points) 124 | assert re.search(r']*id="foo"', points) 125 | 126 | def testAddClass(self): 127 | geom = {'coordinates': (0, 0), 'type': 'Point'} 128 | kwargs = {"class": "boston"} 129 | point = draw.points(geom, **kwargs) 130 | self.assertIsInstance(point, six.string_types) 131 | 132 | point = draw.points(geom, **kwargs) 133 | assert isinstance(point, six.string_types) 134 | 135 | def testDrawPolygonComplicated(self): 136 | coordinates = [ 137 | [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)], 138 | [(4.0, 4.0), (4.0, 5.0), (5.0, 5.0), (5.0, 4.0), (4.0, 4.0)], 139 | ] 140 | 141 | polygon = draw.polygon(coordinates) 142 | self.assertIsInstance(polygon, six.string_types) 143 | assert 'class="polygon"' in polygon 144 | 145 | kw = {'class': 'a'} 146 | assert 'polygon a' in draw.polygon(coordinates, **kw) 147 | 148 | def testUnkownGeometry(self): 149 | with self.assertRaises(errors.SvgisError): 150 | draw.geometry({"type": "FooBar", "coordinates": []}) 151 | 152 | def testGeometryCollection(self): 153 | gc = { 154 | "type": "GeometryCollection", 155 | "id": "GC", 156 | "geometries": [ 157 | self.polygon['geometry'], 158 | self.linestring['geometry'], 159 | self.point['geometry'], 160 | self.multipolygon['geometry'], 161 | self.multilinestring['geometry'], 162 | ], 163 | } 164 | a = draw.geometry(gc, id='cats') 165 | assert isinstance(a, six.string_types) 166 | assert 'id="cats"' in a 167 | 168 | def testDrawAndConvertToString(self): 169 | draw.geometry(self.linestring['geometry']) 170 | draw.geometry(self.multilinestring['geometry']) 171 | draw.geometry(self.polygon['geometry']) 172 | draw.geometry(self.multipolygon['geometry']) 173 | 174 | 175 | if __name__ == '__main__': 176 | unittest.main() 177 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Tests on the svgis command line tool""" 4 | # This file is part of svgis. 5 | # https://github.com/fitnr/svgis 6 | # Licensed under the GNU General Public License v3 (GPLv3) license: 7 | # http://opensource.org/licenses/GPL-3.0 8 | # Copyright (c) 2015-16, Neil Freeman 9 | # pylint: disable=duplicate-code 10 | import io 11 | import os 12 | import re 13 | import sys 14 | import unittest 15 | from xml.dom import minidom 16 | 17 | import click.testing 18 | import fiona.errors 19 | from click import BadParameter 20 | 21 | import svgis.cli 22 | 23 | PROJECTION = '+proj=lcc +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs' 24 | BOUNDS = (-124.0, 20.5, -64.0, 49.0) 25 | 26 | 27 | class CliTestCase(unittest.TestCase): 28 | runner = click.testing.CliRunner() 29 | fixture = 'tests/fixtures/test.svg' 30 | 31 | shp = 'tests/fixtures/cb_2014_us_nation_20m.json' 32 | dc = 'tests/fixtures/tl_2015_11_place.json' 33 | css = 'polygon{fill:green}' 34 | 35 | def setUp(self): 36 | self.assertTrue(os.path.exists(self.dc)) 37 | self.assertTrue(os.path.exists(self.fixture)) 38 | self.assertTrue(os.path.exists(self.shp)) 39 | 40 | def invoke(self, argument, **kwargs): 41 | return self.runner.invoke(svgis.cli.main, argument, catch_exceptions=False, **kwargs) 42 | 43 | def testSvgStyle(self): 44 | sys.stdout = io.StringIO() 45 | self.invoke(['style', '--style', self.css, self.fixture, 'tmp.svg']) 46 | try: 47 | with open('tmp.svg') as f: 48 | self.assertIn(self.css, f.read()[0:1000]) 49 | finally: 50 | os.remove('tmp.svg') 51 | 52 | def testSvgScale(self): 53 | self.invoke(['scale', '--scale', '123', self.fixture, 'tmp.svg']) 54 | try: 55 | with open('tmp.svg') as f: 56 | self.assertIn('scale(123)', f.read()[0:1000]) 57 | 58 | finally: 59 | os.remove('tmp.svg') 60 | 61 | def testBounds(self): 62 | result = self.invoke(['bounds', self.shp]) 63 | self.assertEqual(result.exit_code, 0) 64 | self.assertEqual(result.output.strip(), '-179.174265 17.913769 179.773922 71.352561') 65 | 66 | def testSvgProjectUtm(self): 67 | p = self.invoke(['project', '--method', 'utm', '--', '-110.277906', '35.450777', '-110.000477', '35.649030']) 68 | expected = set('+proj=utm +zone=12 +datum=WGS84 +units=m +no_defs +type=crs'.split(' ')) 69 | self.assertSetEqual(set(p.output.strip().split(' ')), expected) 70 | 71 | def testSvgProject(self): 72 | p = self.invoke(['project', '--', '-110.277906', '35.450777', '-110.000477', '35.649030']) 73 | 74 | self.assertEqual(p.exit_code, 0) 75 | 76 | expected = set( 77 | [ 78 | '+type=crs', 79 | '+lat_0=35.64903', 80 | '+x_0=0', 81 | '+units=m', 82 | '+lon_0=-111', 83 | '+towgs84=0,0,0,0,0,0,0', 84 | '+y_0=0', 85 | '+lat_1=35.64903', 86 | '+proj=lcc', 87 | '+no_defs', 88 | '+lat_2=35.450777', 89 | '+ellps=GRS80', 90 | ] 91 | ) 92 | self.assertSetEqual(set(p.output.strip().split(' ')), expected) 93 | 94 | def testCliDraw(self): 95 | self.invoke( 96 | ['draw', '--crs', PROJECTION, '--scale', '1000', self.shp, '-o', 'tmp.svg', '--viewbox', '--bounds'] 97 | + [str(b) for b in BOUNDS] 98 | ) 99 | 100 | try: 101 | result = minidom.parse('tmp.svg').getElementsByTagName('svg').item(0) 102 | 103 | fixture = minidom.parse(self.fixture).getElementsByTagName('svg').item(0) 104 | 105 | result_vb = [float(x) for x in result.attributes.get('viewBox').value.split(',')] 106 | fixture_vb = [float(x) for x in fixture.attributes.get('viewBox').value.split(',')] 107 | 108 | for r, f in zip(result_vb, fixture_vb): 109 | self.assertAlmostEqual(r, f, 5, 'viewbox doesnt match fixture') 110 | 111 | finally: 112 | os.remove('tmp.svg') 113 | 114 | def testDrawProjected(self): 115 | f = os.path.expanduser('~/tmp.svg') 116 | result = self.invoke(['draw', self.dc, '--output', f, '--precision', '10']) 117 | 118 | self.assertEqual(result.exit_code, 0) 119 | self.assertTrue(os.path.exists(f)) 120 | 121 | try: 122 | with open(f, encoding="utf8") as g: 123 | svg = g.read() 124 | match = re.search(r'points="([^"]+)"', svg) 125 | self.assertIsNotNone(match) 126 | 127 | result = match.groups()[0] 128 | points = [[float(x) for x in p.split(',')] for p in result.split(' ')] 129 | 130 | with fiona.open(self.dc) as src: 131 | ring = next(iter(src))['geometry']['coordinates'][0] 132 | 133 | for points in zip(ring, points): 134 | for z in zip(*points): 135 | self.assertAlmostEqual(*z) 136 | 137 | finally: 138 | os.remove(f) 139 | 140 | def testCliHelp(self): 141 | result = self.invoke(('--help',)) 142 | self.assertEqual(result.exit_code, 0) 143 | 144 | result = self.invoke(['style', '--help']) 145 | assert result.exit_code == 0 146 | 147 | result = self.invoke(['draw', '--help']) 148 | assert result.exit_code == 0 149 | 150 | result = self.invoke(['project', '--help']) 151 | assert result.exit_code == 0 152 | 153 | result = self.invoke(['scale', '--help']) 154 | assert result.exit_code == 0 155 | 156 | def testErrs(self): 157 | with self.assertRaises(fiona.errors.DriverError): 158 | self.invoke(('draw', 'lksdjlksjdf')) 159 | 160 | with self.assertRaises(fiona.errors.DriverError): 161 | self.invoke(('draw', 'zip://lksdjlksjdf.zip/foo')) 162 | 163 | def test_validate_posint(self): 164 | with self.assertRaises(BadParameter): 165 | svgis.cli.validate_posint(None, None, -1) 166 | 167 | with self.assertRaises(BadParameter): 168 | svgis.cli.validate_posint(None, None, 0.5) 169 | 170 | self.assertEqual(svgis.cli.validate_posint(None, None, 1), 1) 171 | 172 | 173 | if __name__ == '__main__': 174 | unittest.main() 175 | -------------------------------------------------------------------------------- /src/svgis/svg.py: -------------------------------------------------------------------------------- 1 | '''Create string versions of SVG elements.''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | 8 | from . import dom, utils 9 | 10 | 11 | def _element(tag, contents=None, **kwargs): 12 | """ 13 | Draw an element, optionally wrapping contents. 14 | 15 | Args: 16 | tag (str): tag name 17 | contents (str): contents to be wrapped in the tag 18 | kwargs: to be transformed into attributes 19 | 20 | Returns: 21 | ``str`` 22 | """ 23 | attribs = toattribs(**kwargs) 24 | if contents: 25 | return f"<{tag}{attribs}>{contents}" 26 | 27 | return f"<{tag}{attribs}/>" 28 | 29 | 30 | def _fmt(precision): 31 | if precision is None: 32 | return "{0[0]},{0[1]}" 33 | return f"{{0[0]:.{precision}f}},{{0[1]:.{precision}f}}" 34 | 35 | 36 | def _poly(name): 37 | def poly(coordinates, precision=None, **kwargs): 38 | fmt = _fmt(precision) 39 | 40 | points = utils.dedupe(fmt.format(c) for c in coordinates) 41 | return _element(name(), points=' '.join(points), **kwargs) 42 | 43 | return poly 44 | 45 | 46 | def circle(point, precision=None, **kwargs): 47 | """ 48 | Create a svg circle element. Keyword arguments are mapped to attributes. 49 | 50 | Args: 51 | point (tuple): The center of the circle 52 | precision (int): rounding precision 53 | 54 | Returns: 55 | ``str`` 56 | """ 57 | return _element('circle', cx=utils.rnd(point[0], precision), cy=utils.rnd(point[1], precision), **kwargs) 58 | 59 | 60 | def text(string, start, precision=None, **kwargs): 61 | """ 62 | Create an svg text element. 63 | 64 | Args: 65 | string (str): text for element 66 | start (tuple): starting coordinate 67 | 68 | Returns: 69 | ``str`` 70 | """ 71 | start = [utils.rnd(i, precision) for i in start] 72 | return _element('text', string, x=start[0], y=start[1], **kwargs) 73 | 74 | 75 | def rect(start, width, height, precision=None, **kwargs): 76 | """ 77 | Create an svg rect element. 78 | 79 | Args: 80 | start (tuple): starting coordinate 81 | width (int): rect width 82 | height (int): rect height 83 | precision (int): rounding precision 84 | 85 | Returns: 86 | ``str`` 87 | """ 88 | start = [utils.rnd(i, precision) for i in start] 89 | width = utils.rnd(width, precision) 90 | height = utils.rnd(height, precision) 91 | 92 | return _element('rect', x=start[0], y=start[1], width=width, height=height, **kwargs) 93 | 94 | 95 | def line(start, end, precision=None, **kwargs): 96 | """ 97 | Create an svg line element. 98 | 99 | Args: 100 | start (tuple): starting coordinate 101 | end (tuple): ending coordinate 102 | precision (int): rounding precision 103 | 104 | Returns: 105 | ``str`` 106 | """ 107 | start = [utils.rnd(i, precision) for i in start] 108 | end = [utils.rnd(i, precision) for i in end] 109 | 110 | return _element('line', x1=start[0], y1=start[1], x2=end[0], y2=end[1], **kwargs) 111 | 112 | 113 | def path(coordinates, precision=None, **kwargs): 114 | """ 115 | Create an svg path element as a string. 116 | 117 | Args: 118 | coordinates (Sequence): A sequence of coordinates and string instructions 119 | precision (int): rounding precision 120 | 121 | Returns: 122 | ``str`` 123 | """ 124 | fmt = _fmt(precision) 125 | coords = utils.dedupe(i if isinstance(i, str) else fmt.format(i) for i in coordinates) 126 | return _element('path', d=' '.join(coords), **kwargs) 127 | 128 | 129 | @_poly 130 | def polyline(): 131 | """ 132 | Create an svg polyline element 133 | 134 | Args: 135 | coordinates (Sequence): x, y coordinates 136 | precision (int): rounding precision 137 | 138 | Returns: 139 | ``str`` 140 | """ 141 | return 'polyline' 142 | 143 | 144 | @_poly 145 | def polygon(): 146 | """ 147 | Create an svg polygon element 148 | 149 | Args: 150 | coordinates (Sequence): x, y coordinates 151 | precision (int): rounding precision 152 | 153 | Returns: 154 | ``str`` 155 | """ 156 | return 'polygon' 157 | 158 | 159 | def toattribs(**kwargs): 160 | """ 161 | Convert keyword arguments to SVG attribute definitions. 162 | 163 | Returns: 164 | ``str`` 165 | """ 166 | attribs = ' '.join(f'{k}="{dom.ampencode(v)}"' for k, v in kwargs.items() if v is not None and v != '') 167 | if attribs: 168 | return ' ' + attribs 169 | 170 | return attribs 171 | 172 | 173 | def defstyle(style=None): 174 | """ 175 | Create a defs element that wraps a CSS style. 176 | 177 | Args: 178 | style (string): A CSS string. 179 | 180 | Returns: 181 | ``str`` 182 | """ 183 | if style: 184 | return f'' 185 | 186 | return '' 187 | 188 | 189 | def group(members=None, **kwargs): 190 | """ 191 | Create a group with the given scale and translation. 192 | 193 | Args: 194 | members (Sequence): unicode SVG elements 195 | kwargs (dict): elements of this dictionary will be converted to 196 | attributes of the group, i.e. key="value". 197 | 198 | Returns: 199 | ``str`` 200 | """ 201 | members = members or '' 202 | return _element('g', ''.join(members), **kwargs) 203 | 204 | 205 | def drawing(size, members, precision=None, viewbox=None, style=None): 206 | """ 207 | Create an SVG element. 208 | 209 | Args: 210 | size (tuple): width, height 211 | members (list): Strings to add to output. 212 | viewbox (Sequence): Four coordinates that describe an SVG viewBox. 213 | style (string): CSS string. 214 | 215 | Returns: 216 | ``str`` 217 | """ 218 | kwargs = { 219 | 'width': size[0], 220 | 'height': size[1], 221 | 'baseProfile': 'full', 222 | 'version': '1.1', 223 | 'xmlns': 'http://www.w3.org/2000/svg', 224 | } 225 | 226 | if precision: 227 | fmt = ('{:.{precision}f}',) 228 | else: 229 | fmt = ('{:f}',) 230 | 231 | if viewbox: 232 | kwargs['viewBox'] = (','.join(fmt * 4)).format(*viewbox, precision=precision) 233 | 234 | contents = defstyle(style) + ''.join(members) 235 | return _element('svg', contents, **kwargs) 236 | -------------------------------------------------------------------------------- /src/svgis/style.py: -------------------------------------------------------------------------------- 1 | '''Mess around with SVG styling''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | # pylint: disable=c-extension-no-member 8 | import logging 9 | import os.path 10 | import re 11 | from string import ascii_letters 12 | 13 | import tinycss2 14 | from lxml import etree 15 | 16 | from . import dom 17 | 18 | LOG = logging.getLogger('svgis') 19 | 20 | 21 | def sanitize(string): 22 | """ 23 | Make input safe of use in an svg ID or class field. 24 | Replaces blocks of whitespace with an underscore (``_``), 25 | deletes periods, number signs and double-quotes (``.#"``). 26 | If the first character isn't an ascii letter, dash (``-``) 27 | or underscore (``_``), an underscore is added to the beginning. 28 | 29 | Args: 30 | string (mixed): Input to sanitize 31 | 32 | Returns: 33 | str 34 | """ 35 | try: 36 | string = re.sub(r'\s+', '_', re.sub(r'(\.|#|"|&)', '', string)) 37 | return string if string[:1] in '_-' + ascii_letters else '_' + string 38 | 39 | except TypeError: 40 | return sanitize(str(string)) 41 | 42 | 43 | def construct_classes(classes, properties): 44 | """ 45 | Build a class string for an element using the properties. Class names 46 | take the form ``myclass_value``. If a given class isn't found in properties, 47 | the class name is added (e.g. ``myclass``). 48 | 49 | Args: 50 | classes (Sequence): Column names to include in the class list 51 | properties (dict): A single feature's properties. 52 | 53 | Returns: 54 | (list) class names 55 | """ 56 | f = '{}_{}' 57 | return [sanitize(f.format(p, properties.get(p))) for p in classes if p in properties] 58 | 59 | 60 | def construct_datas(fields, properties): 61 | """ 62 | Build a data- attribute string for an element using the properties. Attributes 63 | take the form data_FIELD=PROPERTY. 64 | 65 | Args: 66 | datas (Sequence): Column names to include in the class list 67 | properties (dict): A single feature's properties. 68 | 69 | Returns: 70 | (dict) attribute dictionary 71 | """ 72 | return {sanitize('data-' + n): str(properties.get(n)) for n in fields if n in properties} 73 | 74 | 75 | def pick(style): 76 | """ 77 | Fetch a CSS string. 78 | 79 | Args: 80 | style (str): Either a CSS string or the path of a css file. 81 | """ 82 | try: 83 | _, ext = os.path.splitext(style) 84 | if ext == '.css': 85 | with open(style, encoding="utf-8") as f: 86 | return f.read() 87 | 88 | except (AttributeError, TypeError): 89 | # Probably style is None. 90 | return None 91 | 92 | except IOError: 93 | logging.getLogger('svgis').warning("Couldn't read %s, proceeding with default style", style) 94 | 95 | return style 96 | 97 | 98 | def rescale(svgfile, factor): 99 | """Add a ``scale()`` operation to an entire svg file.""" 100 | svg = etree.parse(svgfile).getroot() 101 | scalar = f'scale({factor})' 102 | g = svg.find('.//g', namespaces=svg.nsmap) 103 | g.attrib['transform'] = (g.attrib.get('transform') + ' ' + scalar).strip() 104 | return etree.tostring(svg, encoding='utf-8').decode('utf-8') 105 | 106 | 107 | def replace_comments(css): 108 | """ 109 | Replace one-line non-standard comments with body-style comments:: 110 | 111 | // non-standard comment 112 | /* body-style comment */ 113 | 114 | """ 115 | return re.sub(r'//(.+)', r'/*\1 */', css) 116 | 117 | 118 | def add_style(svgfile, style, replace=False): 119 | """ 120 | Add to or replace the CSS style in an SVG file. 121 | 122 | Args: 123 | svgfile (str): Path to an SVG file or an SVG string. 124 | newstyle (str): CSS string, or path to CSS file. 125 | replace (bool): If true, replace the existing CSS with newstyle (default: False) 126 | """ 127 | if style == '-': 128 | style = '/dev/stdin' 129 | 130 | root, ext = os.path.splitext(style) 131 | 132 | if ext == '.css' or root == '/dev/stdin': 133 | with open(style, encoding="utf-8") as f: 134 | style = replace_comments(f.read()) 135 | 136 | try: 137 | svg = etree.parse(svgfile).getroot() 138 | except IOError: 139 | try: 140 | svg = etree.fromstring(svgfile) 141 | except UnicodeDecodeError: 142 | svg = etree.fromstring(svgfile.encode('utf-8')) 143 | 144 | defs = svg.find('defs', namespaces=svg.nsmap) 145 | 146 | if defs is None: 147 | defs = etree.Element('defs', nsmap=svg.nsmap) 148 | svg.insert(0, defs) 149 | 150 | style_element = defs.find('.//style', namespaces=svg.nsmap) 151 | 152 | if style_element is None: 153 | style_element = etree.Element('style', nsmap=svg.nsmap) 154 | defs.append(style_element) 155 | 156 | if replace: 157 | style_content = style 158 | else: 159 | # Append CSS. 160 | style_content = (style_element.text or '') + ' ' + style 161 | 162 | # append cdata 163 | style_element.text = etree.CDATA(style_content) 164 | 165 | return etree.tostring(svg, encoding='utf-8').decode('utf-8') 166 | 167 | 168 | def inline(svg): 169 | """ 170 | Inline the CSS rules in an SVG. This is a very rough operation, 171 | and full css precedence rules won't be respected. May ignore sibling 172 | operators (``~``, ``+``), pseudo-selectors (e.g. ``:first-child``), and 173 | attribute selectors (e.g. ``.foo[name=bar]``). Works best with rules like: 174 | 175 | * ``.class`` 176 | * ``tag`` 177 | * ``tag.class`` 178 | * ``#layer .class`` 179 | * ``#layer tag`` 180 | 181 | Args: 182 | svg (string): An SVG document. 183 | style (string): CSS to use, instead of the CSS in the element of the SVG. 184 | """ 185 | try: 186 | doc = etree.fromstring(svg.encode('utf-8')) 187 | style_element = doc.find('.//style', namespaces=doc.nsmap) 188 | if style_element is None: 189 | return svg 190 | 191 | css = style_element.text 192 | rules = tinycss2.parse_stylesheet(css, skip_whitespace=True, skip_comments=True) 193 | dom.apply_rules(doc, rules) 194 | return etree.tostring(doc, encoding='utf-8').decode('utf-8') 195 | 196 | # Return plain old SVG. 197 | except (AttributeError, NameError) as e: 198 | logging.getLogger('svgis').warning("Unable to inline CSS: %s", e) 199 | return svg 200 | -------------------------------------------------------------------------------- /tests/test_svgis.py: -------------------------------------------------------------------------------- 1 | """Tests on the SVGIS object.""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, Neil Freeman 7 | # pylint: disable=unused-import 8 | import logging 9 | import re 10 | import unittest 11 | from xml.dom import minidom 12 | 13 | import six 14 | 15 | from svgis import errors, svgis 16 | 17 | 18 | class SvgisTestCase(unittest.TestCase): 19 | file = 'tests/fixtures/cb_2014_us_nation_20m.json' 20 | 21 | chi_files = ['tests/fixtures/chicago_bounds_2790.json', 'tests/fixtures/cook_bounds_4269.json'] 22 | 23 | polygon = { 24 | "properties": { 25 | 'apple': 'fruit', 26 | 'pear': 1, 27 | 'kale': 'leafy green', 28 | }, 29 | "geometry": {"type": "Polygon", "id": "Polygon", "coordinates": [[(0, 0), (1, 0), (0, 1), (0, 0)]]}, 30 | } 31 | 32 | def setUp(self): 33 | logging.getLogger('svgis').setLevel(logging.CRITICAL) 34 | self.svgis_obj = svgis.SVGIS(self.file) 35 | 36 | def assertSequenceAlmostEqual(self, a, b): 37 | for z in zip(a, b): 38 | self.assertAlmostEqual(*z) 39 | 40 | def testSvgisError(self): 41 | with self.assertRaises(errors.SvgisError): 42 | raise errors.SvgisError('This is an error') 43 | 44 | def testSvgisCreate(self): 45 | self.assertEqual(self.svgis_obj.files, [self.file]) 46 | self.assertIsNone(self.svgis_obj._projected_bounds) 47 | assert self.svgis_obj.out_crs is None 48 | assert self.svgis_obj.style == svgis.STYLE 49 | 50 | svgis_obj2 = svgis.SVGIS([self.file]) 51 | assert svgis_obj2.files == [self.file] 52 | 53 | with self.assertRaises(errors.SvgisError): 54 | svgis.SVGIS(12) 55 | 56 | def testSvgisCompose(self): 57 | composed = self.svgis_obj.compose() 58 | assert isinstance(composed, six.string_types) 59 | 60 | def testSvgisClassFields(self): 61 | composed = self.svgis_obj.compose(class_fields=('NAME', 'GEOID')) 62 | 63 | matchiter = re.finditer(r'class="(.+?)"', composed) 64 | match = next(matchiter) 65 | 66 | self.assertIsNotNone(match) 67 | self.assertIn(u'AFFGEOID', match.groups()[0]) 68 | self.assertIn(u'GEOID', match.groups()[0]) 69 | self.assertIn(u'NAME', match.groups()[0]) 70 | 71 | match = next(matchiter) 72 | self.assertIn('GEOID_US', match.groups()[0]) 73 | self.assertIn('cb_2014_us_nation_20m', match.groups()[0]) 74 | 75 | def testRepr(self): 76 | expected = "SVGIS(files=['{}'], " 'out_crs=None)'.format(self.file) 77 | self.assertEqual(str(self.svgis_obj), expected) 78 | 79 | def testDrawGeometry(self): 80 | feat = { 81 | "geometry": { 82 | 'type': 'LineString', 83 | 'coordinates': [[-110.8, 35.3], [-110.9, 35.8], [-110.5, 35.1], [-110.8, 35.3]], 84 | }, 85 | "properties": {'foo': 'bar', 'cat': 'meow'}, 86 | } 87 | drawn = self.svgis_obj.feature(feat, [], classes=['foo'], id_field='cat', name='quux') 88 | assert isinstance(drawn, six.string_types) 89 | 90 | self.assertIn('id="meow"', drawn) 91 | self.assertIn('class="quux foo_bar"', drawn) 92 | 93 | drawn2 = self.svgis_obj.feature(feat, [], classes=['foo'], id_field='cat') 94 | self.assertIn('class="foo_bar"', drawn2) 95 | 96 | feat['geometry'] = None 97 | drawn3 = self.svgis_obj.feature(feat, [], classes=['foo'], id_field='cat') 98 | assert drawn3 == '' 99 | 100 | def testSvgisComposeType(self): 101 | a = self.svgis_obj.compose(inline_css=True) 102 | b = self.svgis_obj.compose(inline_css=False) 103 | 104 | try: 105 | try: 106 | self.assertIsInstance(a, str) 107 | except AssertionError as err: 108 | raise AssertionError(type(a)) from err 109 | 110 | try: 111 | self.assertIsInstance(b, str) 112 | except AssertionError as err: 113 | raise AssertionError(type(b)) from err 114 | 115 | except NameError: 116 | try: 117 | self.assertIsInstance(a, str) 118 | except AssertionError as err: 119 | raise AssertionError(type(a)) from err 120 | 121 | try: 122 | self.assertIsInstance(b, str) 123 | except AssertionError as err: 124 | raise AssertionError(type(b)) from err 125 | 126 | self.assertEqual(type(a), type(b)) 127 | 128 | def testMapFunc(self): 129 | args = { 130 | "scale": 10, 131 | "precision": 1, 132 | "padding": 10, 133 | "inline": True, 134 | "clip": False, 135 | "crs": "EPSG:2790", 136 | } 137 | result = svgis.map(self.chi_files, (-80, 40, -71, 45.1), **args) 138 | self.assertIn(svgis.STYLE, result) 139 | 140 | doc = minidom.parseString(result) 141 | for poly in doc.getElementsByTagName('polygon'): 142 | self.assertIn('polygon', poly.toxml()) 143 | self.assertIn('style', poly.toxml()) 144 | style = poly.getAttribute('style') 145 | self.assertIn('fill:none', style) 146 | self.assertIn('stroke-linejoin:round', style) 147 | # check that points have 1 decimal place 148 | points = poly.getAttribute('points') 149 | x, y = points.split(' ').pop(0).split(',') 150 | assert len(x[x.index('.') + 1 :]) == 1 151 | assert len(y[y.index('.') + 1 :]) == 1 152 | 153 | def testOpenZips(self): 154 | archive = 'zip://tests/fixtures/test.zip/fixtures/cb_2014_us_nation_20m.json' 155 | 156 | result = svgis.SVGIS([archive], simplify=60).compose() 157 | 158 | self.assertIn('cb_2014_us_nation_20m', result) 159 | 160 | def testDrawWithClasses(self): 161 | r0 = self.svgis_obj.feature(self.polygon, [], classes=[], id_field=None, name='potato') 162 | self.assertIn('class="potato"', r0) 163 | 164 | r1 = self.svgis_obj.feature(self.polygon, [], classes=['kale'], id_field='apple') 165 | assert 'kale_leafy_green' in r1 166 | assert 'id="fruit"' in r1 167 | 168 | r2 = self.svgis_obj.feature(self.polygon, [], id_field='pear', classes=['apple', 'pear', 'kale']) 169 | self.assertIn('id="_1"', r2) 170 | assert 'apple_fruit' in r2 171 | assert 'pear_1' in r2 172 | assert 'kale_leafy_green' in r2 173 | 174 | def testIssue8(self): 175 | '''Test for coordinate testing bug in: https://github.com/fitnr/svgis/issues/8''' 176 | s = svgis.SVGIS('tests/fixtures/issue-8.geojson', crs='file') 177 | result = s.compose() 178 | self.assertIn(' 7 | # pylint: disable=unused-import 8 | import os 9 | import re 10 | import unittest 11 | from io import BytesIO, StringIO 12 | from xml.dom import minidom 13 | 14 | try: 15 | from lxml import etree 16 | except ImportError: 17 | import xml.etree.ElementTree as etree 18 | 19 | from svgis import dom, style 20 | 21 | from . import TEST_CSS, TEST_SVG 22 | 23 | 24 | class CssTestCase(unittest.TestCase): 25 | svg = TEST_SVG 26 | css = TEST_CSS 27 | 28 | css1 = '''.class-name { fill: orange;}''' 29 | 30 | file = 'tests/fixtures/test.svg' 31 | 32 | classes = ('apple', 'potato') 33 | 34 | properties = { 35 | 'apple': 'fruit', 36 | 'pear': 1, 37 | 'kale': 'leafy green', 38 | } 39 | 40 | def testInlineCSS(self): 41 | svg = style.add_style(self.svg, self.css) 42 | inlined = style.inline(svg) 43 | self.assertNotEqual(inlined, self.svg) 44 | 45 | doc = minidom.parseString(inlined) 46 | self.assertIn('fill:purple', inlined) 47 | 48 | polygon_style = doc.getElementsByTagName('polygon').item(0).getAttribute('style') 49 | self.assertIn('stroke:green', polygon_style) 50 | self.assertIn('fill:orange', polygon_style) 51 | 52 | cat_style = doc.getElementsByTagName('polyline').item(1).getAttribute('style') 53 | self.assertIn('fill:red', cat_style) 54 | 55 | polyline_style = doc.getElementsByTagName('polyline').item(0).getAttribute('style') 56 | self.assertIn('stroke:blue', polyline_style) 57 | 58 | def test_add_style(self): 59 | new = style.add_style(self.file, self.css) 60 | result = minidom.parseString(new).getElementsByTagName('defs').item(0).getElementsByTagName('style').item(0) 61 | assert self.css in result.toxml() 62 | 63 | def test_replace_style(self): 64 | new = style.add_style(self.file, self.css, replace=True) 65 | result = minidom.parseString(new).getElementsByTagName('defs').item(0).getElementsByTagName('style').item(0) 66 | assert self.css in result.toxml() 67 | 68 | def test_add_style_missing_def(self): 69 | with open(self.file) as f: 70 | replaced_svg = re.sub(r'', '', f.read()) 71 | 72 | try: 73 | io_svg = BytesIO(replaced_svg) 74 | except TypeError: 75 | io_svg = StringIO(replaced_svg) 76 | 77 | new = style.add_style(io_svg, self.css) 78 | result = minidom.parseString(new).getElementsByTagName('defs').item(0).getElementsByTagName('style').item(0) 79 | self.assertIn(self.css, result.toxml()) 80 | 81 | def testReScale(self): 82 | result = style.rescale('tests/fixtures/test.svg', 1.37) 83 | self.assertIn('scale(1.37)', result[0:2000]) 84 | 85 | def testPickStyle(self): 86 | stylefile = 'tmp.css' 87 | 88 | with open(stylefile, 'w') as w: 89 | w.write(self.css) 90 | 91 | try: 92 | result = style.pick(stylefile) 93 | self.assertEqual(self.css, result) 94 | 95 | finally: 96 | os.remove('tmp.css') 97 | 98 | result = style.pick(self.css) 99 | self.assertEqual(self.css, result) 100 | 101 | self.assertIsNone(style.pick(None)) 102 | 103 | def testAddCli(self): 104 | result = style.add_style(self.file, self.css) 105 | self.assertIn(self.css, result[0:2000]) 106 | 107 | cssfile = 'tmp.css' 108 | with open(cssfile, 'w') as w: 109 | w.write(self.css) 110 | 111 | try: 112 | result = style.add_style(self.file, cssfile) 113 | self.assertIn(self.css, result[0:2000]) 114 | 115 | finally: 116 | os.remove('tmp.css') 117 | 118 | def testAddStyleNoDefs(self): 119 | svg = self.svg.replace('', '') 120 | new = style.add_style(svg, self.css) 121 | result = minidom.parseString(new).getElementsByTagName('defs').item(0).getElementsByTagName('style').item(0) 122 | assert self.css in result.toxml() 123 | 124 | def testSanitize(self): 125 | assert style.sanitize(None) == 'None' 126 | assert style.sanitize('') == '' 127 | self.assertEqual(style.sanitize('ü'), '_ü') 128 | self.assertEqual(style.sanitize('!foo'), '_!foo') 129 | assert style.sanitize('müller') == 'müller' 130 | 131 | self.assertEqual(style.sanitize(1), '_1') 132 | 133 | self.assertEqual(style.sanitize('foo.bar'), 'foobar') 134 | self.assertEqual(style.sanitize('fooba.r'), 'foobar') 135 | 136 | self.assertEqual(style.sanitize('.foo'), 'foo') 137 | 138 | self.assertEqual(style.sanitize('foo#bar'), 'foobar') 139 | self.assertEqual(style.sanitize('foobar#'), 'foobar') 140 | 141 | self.assertEqual(style.sanitize('x \t'), 'x_') 142 | 143 | self.assertEqual(style.sanitize('"huh"'), 'huh') 144 | 145 | def testConstructClasses(self): 146 | self.assertEqual(style.construct_classes(('foo',), {'foo': 'bar'}), ['foo_bar']) 147 | self.assertEqual(style.construct_classes(['foo'], {'foo': 'bar'}), ['foo_bar']) 148 | 149 | self.assertEqual(style.construct_classes(['foo'], {'foo': None}), ['foo_None']) 150 | 151 | def testCreateClasses(self): 152 | classes = style.construct_classes(self.classes, self.properties) 153 | self.assertEqual(classes, ['apple_fruit']) 154 | 155 | classes = style.construct_classes(self.classes, {'apple': 'fruit'}) 156 | self.assertEqual(classes, ['apple_fruit']) 157 | 158 | classes = style.construct_classes(self.classes, {'apple': 'früit'}) 159 | self.assertEqual(classes, ['apple_früit']) 160 | 161 | classes = style.construct_classes(self.classes, {'apple': 1}) 162 | self.assertEqual(classes, ['apple_1']) 163 | 164 | def testCreateClassesMissing(self): 165 | classes = style.construct_classes(self.classes, {'apple': ''}) 166 | self.assertEqual(classes, ['apple_']) 167 | 168 | classes = style.construct_classes(self.classes, {'apple': None}) 169 | self.assertEqual(classes, ['apple_None']) 170 | 171 | def testPartialStyleName(self): 172 | inlined = style.inline(self.svg) 173 | self.assertNotIn('orange', inlined) 174 | 175 | def testReplaceComments(self): 176 | css = """ 177 | // foo 178 | """ 179 | result = style.replace_comments(css) 180 | self.assertIn('/* foo */', result) 181 | 182 | def testConstructDataFields(self): 183 | fields = style.construct_datas(['a', 'b'], {'a': '1', 'b': '_ _'}) 184 | self.assertEqual(fields['data-a'], '1') 185 | self.assertEqual(fields['data-b'], '_ _') 186 | self.assertNotIn('data-c', fields) 187 | 188 | 189 | if __name__ == '__main__': 190 | unittest.main() 191 | -------------------------------------------------------------------------------- /src/svgis/draw.py: -------------------------------------------------------------------------------- 1 | '''Draw geometries as SVG elements''' 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2016, 2020, Neil Freeman 7 | from functools import wraps 8 | 9 | from . import svg, transform, utils 10 | from .errors import SvgisError 11 | 12 | 13 | def _applyid(multifunc): 14 | """ 15 | This decorator applies the ID attribute to the group that 16 | contains multi-part geometries, rather than the elements of the group. 17 | """ 18 | 19 | @wraps(multifunc) 20 | def func(coordinates, **kwargs): 21 | ID = kwargs.pop('id', None) 22 | result = svg.group(multifunc(coordinates, **kwargs), id=ID) 23 | return result 24 | 25 | return func 26 | 27 | 28 | def linestring(coordinates, **kwargs): 29 | """Serialize coordinates to a svg line.""" 30 | return svg.polyline(coordinates, **kwargs) 31 | 32 | 33 | @_applyid 34 | def multilinestring(coordinates, **kwargs): 35 | """Serialize lists of coordinates to a multiline svg lines.""" 36 | return (linestring(coords, **kwargs) for coords in coordinates) 37 | 38 | 39 | def lines(geom, **kwargs): 40 | """ 41 | Draw a LineString or MultiLineString geometry. 42 | 43 | Args: 44 | geom (object): A GeoJSON-like LineString or MultiLineString geometry object. 45 | 46 | Returns: 47 | ``str`` representation of the SVG group or polyline element(s). 48 | """ 49 | if geom['type'] == 'LineString': 50 | return linestring(geom['coordinates'], **kwargs) 51 | 52 | if geom['type'] == 'MultiLineString': 53 | return multilinestring(geom['coordinates'], **kwargs) 54 | 55 | raise SvgisError("Unexpected geometry type. Expected LineString or MultiLineString, but got: " + geom['type']) 56 | 57 | 58 | def polygons(geom, **kwargs): 59 | """ 60 | Draw polygon(s) in a feature. transform is a function to operate on coords. 61 | Draws first ring clockwise, and subsequent ones counter-clockwise. 62 | 63 | Args: 64 | geom (object): A GeoJSON-like Polygon or MultiPolygon geometry object. 65 | 66 | Returns: 67 | ``str`` representation of the SVG group, path or polygon element. 68 | """ 69 | if geom['type'] == 'Polygon': 70 | return polygon(geom['coordinates'], **kwargs) 71 | 72 | if geom['type'] == 'MultiPolygon': 73 | return multipolygon(geom['coordinates'], **kwargs) 74 | 75 | raise SvgisError("Unexpected geometry type. Expected Polygon or MultiPolygon, but got: " + geom['type']) 76 | 77 | 78 | def polygon(coordinates, **kwargs): 79 | """ 80 | Serialize lists of coordinates to a svg polygon. 81 | 82 | Arguments: 83 | coordinates (sequence): Sequence of rings. 84 | 85 | Returns: 86 | ``str`` representation of an svg ``path`` 87 | """ 88 | kwargs.setdefault("fill-rule", "evenodd") 89 | if len(coordinates) == 1: 90 | return svg.polygon(coordinates[0], **kwargs) 91 | 92 | kwargs['class'] = ('polygon ' + kwargs.pop('class', '')).strip() 93 | 94 | instructions = ['M'] 95 | # This is trickier because drawing holes in SVG. 96 | # We go clockwise on the first ring, then counterclockwise 97 | if utils.counterclockwise(coordinates[0]): 98 | instructions.extend(coordinates[0][::-1]) 99 | else: 100 | instructions.extend(coordinates[0]) 101 | 102 | instructions.append('z') 103 | 104 | for ring in coordinates[1:]: 105 | # make all interior run the counter-clockwise 106 | instructions.append('M') 107 | instructions.extend(ring[::-1] if utils.clockwise(ring) else ring) 108 | instructions.append('z') 109 | 110 | return svg.path(instructions, **kwargs) 111 | 112 | 113 | @_applyid 114 | def multipolygon(coordinates, **kwargs): 115 | """Serialize lists of lists of coordinates to multiple svg polygons.""" 116 | return (polygon(coords, **kwargs) for coords in coordinates) 117 | 118 | 119 | def points(geom, **kwargs): 120 | """ 121 | Draw a Point or MultiPoint geometry 122 | 123 | Args: 124 | geom (object): A GeoJSON-like Point or MultiPoint geometry object. 125 | 126 | Returns: 127 | ``str`` representation of the SVG group, or circle element 128 | """ 129 | kwargs['r'] = kwargs.get('r', 1) 130 | 131 | if geom['type'] == 'Point': 132 | return svg.circle(geom['coordinates'], **kwargs) 133 | 134 | if geom['type'] == 'MultiPoint': 135 | return multipoint(geom['coordinates'], **kwargs) 136 | 137 | raise SvgisError("Unexpected geometry type. Expected Point or MultiPoint, but got: " + geom['type']) 138 | 139 | 140 | @_applyid 141 | def multipoint(coordinates, **kwargs): 142 | """Serialize coordinates to multiple svg points.""" 143 | return (svg.circle((pt[0], pt[1]), **kwargs) for pt in coordinates) 144 | 145 | 146 | def geometrycollection(collection, bbox, precision, **kwargs): 147 | """Serialize diverse geometry rtpes to svg.""" 148 | ID = kwargs.pop('id', None) 149 | geoms = (geometry(g, bbox=bbox, precision=precision, **kwargs) for g in collection['geometries']) 150 | return svg.group(geoms, id=ID) 151 | 152 | 153 | def geometry(geom, bbox=None, precision=None, **kwargs): 154 | """ 155 | Draw a geometry. Will return either a single geometry or a group. 156 | 157 | Args: 158 | geom (object): A GeoJSON-like geometry object. Coordinates must be 2-dimensional. 159 | bbox (tuple): An optional bounding minimum bounding box 160 | precision (int): Rounding precision, must be 0 or greater (default: no rounding). 161 | kwargs (object): keyword args to be passed onto the created 162 | elements (e.g. class, id, style). 163 | 164 | Returns: 165 | ``str`` representation of SVG element(s) of the given geometry. 166 | """ 167 | try: 168 | if bbox: 169 | geom = transform.clip(geom, bbox) 170 | 171 | if geom['type'] in ('Point', 'MultiPoint'): 172 | return points(geom, precision=precision, **kwargs) 173 | 174 | if geom['type'] in ('LineString', 'MultiLineString'): 175 | return lines(geom, precision=precision, **kwargs) 176 | 177 | if geom['type'] in ('Polygon', 'MultiPolygon'): 178 | return polygons(geom, precision=precision, **kwargs) 179 | 180 | if geom['type'] == 'GeometryCollection': 181 | return geometrycollection(geom, bbox, precision, **kwargs) 182 | 183 | except Exception as e: 184 | raise SvgisError(f"Error drawing feature: {e}") from e 185 | 186 | raise SvgisError(f"Can't draw features of type: {geom['type']}") 187 | 188 | 189 | def group(geometries, **kwargs): 190 | """ 191 | Add a list of geometries to a group. 192 | 193 | Args: 194 | geometries (Sequence): GeoJSON-like geometry dicts. 195 | 196 | Returns: 197 | ``str`` representation of the SVG group 198 | """ 199 | kwargs.setdefault("fill-rule", "evenodd") 200 | return svg.group([geometries(g, **kwargs) for g in geometries]) 201 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | 0.5.3 2 | ----- 3 | * Correct "fill-rule" attribute (#11) 4 | * Remove Python 3.6, 3.7 from list of supported versions 5 | 6 | 0.5.2 7 | ----- 8 | * Fix an error checking certain geometries (#8) 9 | * Migrate CI workflows from Travis to Github Actions 10 | * Remove Python 3.5 from list of supported versions 11 | 12 | 0.5.1 13 | ----- 14 | 15 | * Improve Windows compatibility (#5) 16 | * Improve projection handling (#4) 17 | * Update documentation 18 | 19 | 0.5.0 20 | ----- 21 | 22 | * Drop Python 2 support 23 | * Add "data_fields" option to generate data attributes in output SVG. 24 | * Update for newer pyproj and Fiona APIs 25 | * Shift CSS handling from tinycss to tinycss2 26 | * Add requirements for cssselect, move lxml from optional to required 27 | 28 | 0.4.6 29 | ----- 30 | 31 | * bump fiona requirements to 1.8 32 | 33 | 0.4.5 34 | ----- 35 | 36 | * bump requirements 37 | 38 | 0.4.4 39 | ----- 40 | 41 | * bump fiona requirements, now compatible with GDAL v2. 42 | * Ignore id_field in layers without that field 43 | * Sanitize ampersands in data fields 44 | * Allow an x/y pair argument to `svgis project`. 45 | 46 | 0.4.3 47 | ----- 48 | 49 | * Improve CSS parsing and creating in `style` 50 | * Simplify string conversion in `svg` 51 | * Expand and update tests a bit 52 | 53 | 0.4.2 54 | ----- 55 | 56 | * Bump fionautil requirement, fixing bug that occurs when Numpy is missing. 57 | 58 | 0.4.1 59 | ----- 60 | 61 | * Add support for zipped data 62 | * Change "svgis style --style" shorthand option from -s to -c 63 | * Improve support for custom map drawing workflows 64 | * Change keyword argument for SVGIS from out_crs to crs 65 | * Shorthand reprojections now work for with data in any projection 66 | * build as a universal wheel 67 | * Fix bug for unicode attributes in Python 2 (0.4.0 regression) 68 | * Fix bug with certain uses of 'file' projection keyword 69 | * Fix error on multipolygon coordinates in unexpanded generators 70 | 71 | 0.4.0 72 | ----- 73 | 74 | * Make --no-viewbox default in svgis draw 75 | * Refactor internal bounds handling, squashing a few bugs 76 | * Better handling for precision and padding in svgis.SVGIS 77 | * Filter out successive identical coordinates in geometries 78 | * Fix padding to truly be in output (projected) units 79 | * Warn, don't crash, when geometry is null 80 | * Additional verbose flags now provide more debugging infomation 81 | * Repair bug that sometimes added styles based on a class substring, rather than exact match 82 | * Put r attributes right on the element when inlining 83 | * Fix bug when layer contained a field with its name 84 | * Restructured modules: renamed 'clip' and 'convert' to 'transform' and 'bounding' 85 | 86 | 0.3.10 87 | ------ 88 | 89 | * Fix CDATA bug with svgis style 90 | * refactoring, PEP 8 formatting 91 | * Fix bug that ignored precision 92 | * Correctly identify and use projections in projected files 93 | 94 | 0.3.9 95 | ----- 96 | 97 | * Remove periods and number signs from classes and ids. 98 | * Refactor classing functions to svgis.style 99 | * Improve error messages when drawing features. 100 | 101 | 0.3.8 102 | ----- 103 | 104 | * Make inlined styles default 105 | * Handle empty files without complaining 106 | * ignore topological errors when clipping 107 | 108 | 0.3.7 109 | ----- 110 | 111 | * Add precision argument for `svgis draw` 112 | 113 | 0.3.6 114 | ----- 115 | 116 | * Add `svgis graticule` command line tool 117 | * Ensure no repeated style rules when inlining CSS. 118 | * Round numbers at the last minute in the svg module. This is quicker. 119 | * Improve py 2/3 compatibility, esp. when testing. 120 | 121 | 0.3.5 122 | ----- 123 | 124 | * Fix problem reprojecting bounds with mixed projections. 125 | * Add cli tool for getting bounds of a layer 126 | * Repair --verbose option. 127 | * Add `svgis bounds` command line tool for checking the bounds of a layer. 128 | * Expand tests (coverage now above 90%) 129 | 130 | 0.3.4 131 | ----- 132 | 133 | * change `--project` option to `--crs`. 134 | * Fix error with empty CSS selectors 135 | * Add quiet and verbose logging options to `svgis draw`. 136 | * Fix simplification in `svgis draw`. 137 | * Ensure that geojson layers get a pretty name. 138 | * Regularize `svgis.svg`, adding tools for creating more SVG elements, even those not directly used here. 139 | * Try, just slightly, not to have infinite bounds 140 | * Expand docs. 141 | 142 | 0.3.3 143 | ----- 144 | 145 | * Switch from `argparse` to `click` for cli functions. Much better performance, same options. 146 | * Switch `--simplify` argument to accept an integer between 1-99 147 | * Change `--project/-j` option in `svgis project` to `--method/-m` 148 | * Remove lxml dependency for inlining CSS. 149 | * Completely refactor functions that parse XML to use ElementTree (quicker than minidom). 150 | * Add column names to class of layer group. 151 | * Prevent broken pipes 152 | * Squash several bugs related to setting class fields. 153 | * Squash bugs in drawing certain paths. 154 | * Remove duplicate/unused code. 155 | * Ensure use of unicode internally, fixed some small Py3 bugs. 156 | * More tests and more docs! 157 | 158 | 0.3.2 159 | ----- 160 | 161 | * Fix bug introduced in 0.3.1, caused improper bounds in output SVGs. 162 | * Add `svgis.map` function as a shorthand for working with the API 163 | 164 | 0.3.1 165 | ----- 166 | 167 | * Add option to clip files, requires Shapely 168 | * Add option to inline files, requires lxml 169 | * Add line simplification option using Visivalingam algorithm, requires numpy 170 | * Remove svgwrite as a dependency for faster file writing 171 | * Tests expanded and code refactored, crushing lots of bugs 172 | * --proj option can now read a file containing a proj4 string 173 | * Allow unicode in class and id fields 174 | 175 | 0.2.5 176 | ----- 177 | 178 | * Accept a text file containing a proj4 string in `svgis draw --project` 179 | * fix typo in cli help 180 | * add version option to cli 181 | 182 | 0.2.3 183 | ----- 184 | 185 | * Fix class bug for NULL values 186 | 187 | 0.2.2 188 | ----- 189 | 190 | * Prefix data classes with field name 191 | * Remove test data from build 192 | 193 | 0.2.1 194 | ----- 195 | 196 | * Add layer name to class list to get around ID issues in some SVG clients. 197 | 198 | 0.2.0 199 | ----- 200 | * Simplify and update the draw api: draw.geometry now returns either a single svgwrite shape object or a svgwrite group. 201 | * Fix errors when input has a Z coordinate 202 | * Better bounds handling 203 | * Fix numpy errors when drawing MultiPolygons 204 | * --style flag now accepts a css file 205 | * Expand tests 206 | * Remove OSM support, which was broken and not easily fixable 207 | * Move scale functions to sibling project fionautil 208 | 209 | 0.1.4 210 | ----- 211 | 212 | * Project bounds as each file is parsed, rather than fussily at the end 213 | * Simplify feature drawing and argument-passing 214 | * Fix a NAD32-for-WGS84 typo in osm. 215 | * Add 'svgis project' command line tool, for generating proj.4 strings 216 | * Add tests 217 | 218 | 0.1.3 219 | ----- 220 | 221 | * Add ability to read OSM files (if slowly) 222 | * bug fixes in reading, writing 223 | 224 | 0.1.2 225 | ----- 226 | 227 | * Add --no-viewbox option to create translated SVGs, rather than viewboxed ones 228 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # svgis documentation build configuration file, created by 3 | # sphinx-quickstart on Tue Jan 26 11:23:58 2016. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.doctest', 33 | 'sphinx.ext.coverage', 34 | 'sphinx.ext.napoleon', 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The encoding of source files. 46 | #source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = u'svgis' 53 | copyright = u'2020, Neil Freeman' 54 | author = u'Neil Freeman' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | with open(os.path.join(os.path.dirname(__file__), '..', 'src/svgis/__init__.py')) as i: 61 | release = version = next(r for r in i.readlines() if '__version__' in r).split('=')[1].strip('"\' \n') 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | #today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ['_build'] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | #keep_warnings = False 103 | 104 | # If true, `todo` and `todoList` produce output, else they produce nothing. 105 | todo_include_todos = False 106 | 107 | 108 | # -- Options for HTML output ---------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | html_theme = 'sphinx_rtd_theme' 113 | nosidebar = True 114 | # Theme options are theme-specific and customize the look and feel of a theme 115 | # further. For a list of options available for each theme, see the 116 | # documentation. 117 | #html_theme_options = {} 118 | 119 | # Add any paths that contain custom themes here, relative to this directory. 120 | #html_theme_path = [] 121 | 122 | # The name for this set of Sphinx documents. If None, it defaults to 123 | # " v documentation". 124 | #html_title = None 125 | 126 | # A shorter title for the navigation bar. Default is the same as html_title. 127 | #html_short_title = None 128 | 129 | # The name of an image file (relative to this directory) to place at the top 130 | # of the sidebar. 131 | #html_logo = None 132 | 133 | # The name of an image file (within the static path) to use as favicon of the 134 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 135 | # pixels large. 136 | #html_favicon = None 137 | 138 | # Add any paths that contain custom static files (such as style sheets) here, 139 | # relative to this directory. They are copied after the builtin static files, 140 | # so a file named "default.css" will overwrite the builtin "default.css". 141 | html_static_path = ['_static'] 142 | 143 | # Add any extra paths that contain custom files (such as robots.txt or 144 | # .htaccess) here, relative to this directory. These files are copied 145 | # directly to the root of the documentation. 146 | #html_extra_path = [] 147 | 148 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 149 | # using the given strftime format. 150 | #html_last_updated_fmt = '%b %d, %Y' 151 | 152 | # If true, SmartyPants will be used to convert quotes and dashes to 153 | # typographically correct entities. 154 | #html_use_smartypants = True 155 | 156 | # Custom sidebar templates, maps document names to template names. 157 | #html_sidebars = {} 158 | 159 | # Additional templates that should be rendered to pages, maps page names to 160 | # template names. 161 | #html_additional_pages = {} 162 | 163 | # If false, no module index is generated. 164 | #html_domain_indices = True 165 | 166 | # If false, no index is generated. 167 | #html_use_index = True 168 | 169 | # If true, the index is split into individual pages for each letter. 170 | #html_split_index = False 171 | 172 | # If true, links to the reST sources are added to the pages. 173 | html_show_sourcelink = False 174 | 175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 176 | #html_show_sphinx = True 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 179 | #html_show_copyright = True 180 | 181 | # If true, an OpenSearch description file will be output, and all pages will 182 | # contain a tag referring to it. The value of this option must be the 183 | # base URL from which the finished HTML is served. 184 | #html_use_opensearch = '' 185 | 186 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 187 | #html_file_suffix = None 188 | 189 | # Language to be used for generating the HTML full-text search index. 190 | # Sphinx supports the following languages: 191 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 192 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 193 | #html_search_language = 'en' 194 | 195 | # A dictionary with options for the search language support, empty by default. 196 | # Now only 'ja' uses this config value 197 | #html_search_options = {'type': 'default'} 198 | 199 | # The name of a javascript file (relative to the configuration directory) that 200 | # implements a search results scorer. If empty, the default will be used. 201 | #html_search_scorer = 'scorer.js' 202 | 203 | # Output file base name for HTML help builder. 204 | htmlhelp_basename = 'svgisdoc' 205 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/svgis.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/svgis.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/svgis" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/svgis" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /src/svgis/cli.py: -------------------------------------------------------------------------------- 1 | """Command-line utilities for SVGIS.""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://www.opensource.org/licenses/GNU General Public License v3 (GPLv3)-license 6 | # Copyright (c) 2016, Neil Freeman 7 | import logging 8 | import sys 9 | import warnings 10 | 11 | import click 12 | import fiona.crs 13 | 14 | from . import __version__, bounding 15 | from . import graticule as _graticule 16 | from . import projection 17 | from . import style as _style 18 | from . import svgis 19 | from .utils import DEFAULT_GEOID 20 | 21 | none = {'flag_value': None, 'expose_value': False, 'help': '(not enabled)'} 22 | 23 | try: 24 | # pylint: disable=unused-import 25 | import shapely 26 | 27 | clipkwargs = { 28 | 'default': True, 29 | 'flag_value': True, 30 | 'help': "Clip shapes to bounds. Slightly slower, produces smaller files (default: clip).", 31 | } 32 | except ImportError: 33 | clipkwargs = none 34 | 35 | try: 36 | # pylint: disable=unused-import 37 | import visvalingamwyatt 38 | 39 | simplifykwargs = { 40 | 'type': click.IntRange(1, 100, clamp=True), 41 | 'metavar': 'FACTOR', 42 | 'help': ( 43 | 'Simplify geometries, ' 44 | 'accepts an integer between 1 and 100, ' 45 | 'the percentage of each geometry to retain.' 46 | ), 47 | } 48 | except ImportError: 49 | simplifykwargs = none 50 | 51 | CLICKARGS = {'context_settings': dict(help_option_names=['-h', '--help'])} 52 | 53 | csskwargs = { 54 | 'flag_value': True, 55 | 'default': True, 56 | 'help': ( 57 | 'Inline CSS styles to each element. ' 58 | 'Slightly slower, but required by some clients (e.g. Adobe) ' 59 | '(default: inline).' 60 | ), 61 | } 62 | 63 | inp = click.argument('layer', default=sys.stdin, type=click.File('rb')) 64 | outp = click.argument('output', default=sys.stdout, type=click.File('wb')) 65 | 66 | 67 | # Base 68 | @click.group(**CLICKARGS) 69 | @click.version_option(version=__version__, message='%(prog)s %(version)s') 70 | @click.pass_context 71 | def main(context): 72 | """Entry-point for svgis command-line interface.""" 73 | context.log = logging.getLogger('svgis') 74 | context.log.setLevel(logging.WARN) 75 | ch = logging.StreamHandler() 76 | ch.setLevel(logging.WARN) 77 | context.log.addHandler(ch) 78 | 79 | 80 | # Style 81 | style_help = "Style to append to SVG. Either a valid CSS string, a file path (must end in '.css'). Use '-' for stdin." 82 | 83 | 84 | def validate_posint(_, __, value): 85 | """Validate a positive integer""" 86 | if value <= 0 or not isinstance(value, int): 87 | raise click.BadParameter("Should be a positive integer") 88 | return value 89 | 90 | 91 | @main.command() 92 | @inp 93 | @outp 94 | @click.option('-c', '--style', type=str, help=style_help, default='') 95 | @click.option('-r', '--replace', flag_value=True, help='Replace existing styles') 96 | @click.option('--inline/--no-inline', '-l/ ', **csskwargs) 97 | def style(layer, output, **kwargs): 98 | """Add or inline the CSS styles of an SVG""" 99 | result = _style.add_style(layer, kwargs['style'], kwargs['replace']) 100 | if kwargs['inline']: 101 | result = _style.inline(result) 102 | click.echo(result.encode('utf-8'), file=output) 103 | 104 | 105 | @main.command() 106 | @inp 107 | @outp 108 | @click.option('-f', '--scale', type=int) 109 | def scale(layer, output, **kwargs): 110 | '''Scale all coordinates in an SVG by a factor''' 111 | click.echo(_style.rescale(layer, factor=kwargs['scale']).encode('utf-8'), file=output) 112 | 113 | 114 | crs_help = ( 115 | 'Specify a map projection. ' 116 | 'Accepts either an EPSG code (e.g. epsg:4456), ' 117 | 'a proj4 string, ' 118 | 'a file containing a proj4 string, ' 119 | '"utm" (use local UTM), ' 120 | '"file" (use existing), ' 121 | '"local" (generate a local projection)' 122 | ) 123 | 124 | 125 | @main.command() 126 | @click.argument('layer', type=click.Path(exists=True)) 127 | @click.option('-j', '--crs', type=str, metavar='KEYWORD', default='file', help=crs_help + ' (default: file)') 128 | @click.option('--latlon', default=False, flag_value=True, help='Print bounds in latitude, longitude order') 129 | def bounds(layer, crs, latlon=False): 130 | """Return the bounds for a given layer, optionally projected.""" 131 | with fiona.Env(): 132 | with fiona.open(layer, "r") as f: 133 | meta = {'bounds': f.bounds} 134 | meta.update(f.meta) 135 | 136 | warnings.filterwarnings("ignore") 137 | 138 | # If crs==file, these will basically be no ops. 139 | out_crs = projection.pick(crs, meta['bounds'], file_crs=meta['crs']) 140 | result = bounding.transform(meta['bounds'], in_crs=meta['crs'], out_crs=out_crs) 141 | 142 | if latlon: 143 | fmt = '{0[1]} {0[0]} {0[3]} {0[2]}' 144 | else: 145 | fmt = '{0[0]} {0[1]} {0[2]} {0[3]}' 146 | 147 | click.echo(fmt.format(result), file=sys.stdout) 148 | 149 | 150 | # Draw 151 | @main.command() 152 | @click.argument('layer', nargs=-1, type=str, required=True) 153 | @click.option('-o', '--output', default=sys.stdout, type=click.File('wb'), help="Defaults to stdout") 154 | @click.option( 155 | '-b', 156 | '--bounds', 157 | nargs=4, 158 | type=float, 159 | metavar="minx miny maxx maxy", 160 | help='In the same coordinate system as the first input layer', 161 | default=[None, None, None, None], 162 | ) 163 | @click.option('-c', '--style', type=str, metavar='CSS', help="CSS file or string", multiple=True) 164 | @click.option('-f', '--scale', type=int, default=None, help='Scale for the map (units are divided by this number)') 165 | @click.option('-p', '--padding', type=int, default=None, required=None, help='Buffer the map (in projection units)') 166 | @click.option('-i', '--id-field', type=str, metavar='FIELD', help='Geodata field to use as ID') 167 | @click.option( 168 | '-a', 169 | '--class-fields', 170 | type=str, 171 | metavar='FIELDS', 172 | multiple=True, 173 | help='Geodata fields to use as class (comma-separated)', 174 | ) 175 | @click.option( 176 | '-a', 177 | '--data-fields', 178 | type=str, 179 | metavar='FIELDS', 180 | multiple=True, 181 | help='Geodata fields to add as data-* attributes (comma-separated)', 182 | ) 183 | @click.option('-j', '--crs', metavar='KEYWORD', type=str, help=crs_help) 184 | @click.option('-s', '--simplify', **simplifykwargs) 185 | @click.option( 186 | '-P', 187 | '--precision', 188 | metavar='INTEGER', 189 | type=int, 190 | default=5, 191 | callback=validate_posint, 192 | help='Rounding precision for coordinates (default: 5)', 193 | ) 194 | @click.option('--clip/--no-clip', ' /-n', **clipkwargs) 195 | @click.option('--inline/--no-inline', '-l/ ', **csskwargs) 196 | @click.option('--viewbox/--no-viewbox', ' /-x', default=False, help='Draw SVG using a ViewBox (default: no ViewBox)') 197 | @click.option('-q', '--quiet', default=False, flag_value=True, help='Ignore warnings') 198 | @click.option('-v', '--verbose', default=False, count=True, help='Talk a lot') 199 | def draw(layer, output, **kwargs): 200 | """Draw SVGs from input geodata""" 201 | log = logging.getLogger('svgis') 202 | verbose = kwargs.pop('verbose', None) 203 | if verbose: 204 | level = logging.DEBUG if verbose > 1 else logging.INFO 205 | log.setLevel(level) 206 | for h in log.handlers: 207 | h.setLevel(level) 208 | 209 | if kwargs.pop('quiet', None): 210 | log.handlers[0].setLevel(logging.ERROR) 211 | log.setLevel(logging.ERROR) 212 | 213 | click.echo(svgis.map(layer, **kwargs).encode('utf-8'), file=output) 214 | log.info('writing %s', output.name) 215 | 216 | 217 | # Proj 218 | @main.command() 219 | @click.argument('bounds', nargs=4, metavar="MINX MINY MAXX MAXY", required=True, type=float) 220 | @click.option('-m', '--method', default='local', type=click.Choice(('utm', 'local')), help='Defaults to local') 221 | @click.option('-j', '--crs', default=DEFAULT_GEOID, help='Projection of the bounding coordinates') 222 | def project(bounds, method, crs): 223 | """ 224 | Get a local Transverse Mercator or UTM projection for a bounding box. 225 | Expects WGS84 coordinates. 226 | """ 227 | # pylint: disable=redefined-outer-name 228 | if crs in projection.METHODS: 229 | click.echo('CRS must be an EPSG code, a Proj4 string, or file containing a Proj4 string.', err=1) 230 | return 231 | 232 | result = projection.pick(method, file_crs=crs, bounds=bounds).to_proj4() 233 | click.echo(result.encode('utf-8')) 234 | 235 | 236 | crs_help2 = ( 237 | 'Specify a map projection. ' 238 | 'Accepts either an EPSG code (e.g. epsg:4456), ' 239 | 'a proj4 string, ' 240 | 'a file containing a proj4 string, ' 241 | '"utm" (use local UTM), ' 242 | '"local" (generate a local projection)' 243 | ) 244 | 245 | 246 | # Graticule 247 | @main.command() 248 | @click.argument('bounds', nargs=4, type=float, metavar="MINX MINY MAXX MAXY") 249 | @click.option('-s', '--step', type=float, help='Step between lines (in projected units)', required=True) 250 | @click.option('-j', '--crs', type=str, default=None, help=crs_help2) 251 | @click.option('-o', '--output', default=sys.stdout, type=click.File('wb'), help="Defaults to stdout") 252 | def graticule(bounds, step, crs, output): 253 | """ 254 | Generate a GeoJSON containing a graticule. 255 | Accepts a bounding box in longitude and latitude (WGS84). 256 | """ 257 | # pylint: disable=redefined-outer-name 258 | click.echo(_graticule.geojson(bounds, step, crs), file=output) 259 | -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Command-line usage 3 | ================== 4 | 5 | The main function of the SVGIS command line tool is to draw maps. SVGIS also comes with several helper tools for modifying maps and getting generating map projections. 6 | 7 | svgis draw 8 | ========== 9 | 10 | Draw SVGs from input geodata. 11 | :: 12 | 13 | Usage: svgis draw [OPTIONS] LAYER... 14 | 15 | Draw SVGs from input geodata 16 | 17 | Options: 18 | -o, --output FILENAME Defaults to stdout 19 | -b, --bounds minx miny maxx maxy 20 | In the same coordinate system as the first 21 | input layer 22 | -c, --style CSS CSS file or string 23 | -f, --scale INTEGER Scale for the map (units are divided by this 24 | number) 25 | -p, --padding INTEGER Buffer the map (in projection units) 26 | -i, --id-field FIELD Geodata field to use as ID 27 | -a, --class-fields FIELDS Geodata fields to use as class (comma- 28 | separated) 29 | -a, --data-fields FIELDS Geodata fields to add as data-* attributes 30 | (comma-separated) 31 | -j, --crs KEYWORD Specify a map projection. Accepts either an 32 | EPSG code (e.g. epsg:4456), a proj4 string, 33 | a file containing a proj4 string, "utm" (use 34 | local UTM), "file" (use existing), "local" 35 | (generate a local projection) 36 | -s, --simplify FACTOR Simplify geometries, accepts an integer 37 | between 1 and 100, the percentage of each 38 | geometry to retain. 39 | -P, --precision INTEGER Rounding precision for coordinates (default: 40 | 5) 41 | --clip / -n, --no-clip Clip shapes to bounds. Slightly slower, 42 | produces smaller files (default: clip). 43 | -l, --inline / --no-inline Inline CSS styles to each element. Slightly 44 | slower, but required by some clients (e.g. 45 | Adobe) (default: inline). 46 | --viewbox / -x, --no-viewbox Draw SVG using a ViewBox (default: no 47 | ViewBox) 48 | -q, --quiet Ignore warnings 49 | -v, --verbose Talk a lot 50 | -h, --help Show this message and exit. 51 | 52 | 53 | layers 54 | ^^^^^^ 55 | 56 | Layers may be paths or an zip/gzip archive:: 57 | 58 | svgis draw data/cook.shp data/lake.shp 59 | svgis draw zip://archive.zip/lorain.shp zip://archive.zip/cuyahoga.shp 60 | svgis draw tar://archive.tar.gz/boston.geojson tar://archive.tar.gz/cambridge.geojson 61 | 62 | 63 | bounds 64 | ^^^^^^ 65 | 66 | Takes four arguments in min-lon, min-lat, max-lon, max-lat order. 67 | 68 | This example draw the portion of the input file between latitudes 40 and 69 | 41 and longitudes -74 and -73 (roughly the New York City area). 70 | 71 | .. code:: bash 72 | 73 | svgis draw --bounds -74 40 -73 41 in.geojson out.svg 74 | 75 | Note that coordinates are given in longitude, latitude order, since in 76 | the world of the computer, it's better to be consistent with things like 77 | (x, y) order. 78 | 79 | scale 80 | ^^^^^ 81 | 82 | A integer scale. The map will be scaled by 1 over this number. In other words, 83 | for a map at a 1:1000 scale, use: 84 | 85 | .. code:: bash 86 | 87 | svgis draw --scale 1000 in.shp -o out.svg 88 | 89 | This will produce a map where 1000 map units are scaled to one SVG unit. Clients 90 | will vary in how they represent a single unit (pixels, fractions of an inch). 91 | Additionally, clients may have trouble handling very large numbers, so using the 92 | scale option can be a handy way to produce usable drawings. 93 | 94 | Scale won't alter geometries in any way other than scaling. To create smaller 95 | drawings, use the :ref:`simplify` option. 96 | 97 | (The shorthand option for ``--scale`` is ``-f`` as in factor.) 98 | 99 | crs 100 | ^^^^^^^ 101 | 102 | The ``crs`` argument accepts a particular projection or a keyword that 103 | helps SVGIS pick a projection for you. It accepts: 104 | 105 | - `EPSG `__ code 106 | - Proj4 string 107 | - A text file containing a Proj4 string 108 | - Either the 'utm' or 'local' keyword 109 | 110 | If this flag isn't given, SVGIS will check to see if the file is already in 111 | non lat-lng projection (e.g. a state plane system or the British 112 | National Grid). If the first input file is projected, that projection 113 | will be used for the output. If the first file is in lat-long 114 | coordinates, a local projection will be generated, just like if 115 | ``--crs=local`` was given. 116 | 117 | This example will draw an svg with `EPSG:2908 `__, 118 | the New York Long Island state plane projection: 119 | 120 | .. code:: bash 121 | 122 | svgis draw --crs EPSG:2908 nyc.shp -o nyc.svg 123 | 124 | This example uses a Proj.4 string to draw with the `North America Albers 125 | Equal Area Conic `__ projection, which doesn't 126 | have an EPSG code. 127 | 128 | .. code:: bash 129 | 130 | svgis draw in.shp -o out.svg \ 131 | --crs "+proj=aea +lat_1=20 +lat_2=60 +lat_0=40 \ 132 | +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs" 133 | 134 | This is equivalent to the above, and uses a proj.4 file: 135 | 136 | .. code:: bash 137 | 138 | echo "+proj=aea +lat_1=20 +lat_2=60 +lat_0=40 \ 139 | +lon_0=-96 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs" > proj4.txt 140 | 141 | svgis draw in.shp --crs proj4.txt -o out.svg 142 | 143 | With the ``utm`` keyword, SVGIS attempts to draw coordinates in the 144 | local UTM projection. The centerpoint of the bounding box will be used 145 | to pick the zone. Expect poor results for input data that crosses 146 | several UTM boundaries. 147 | 148 | .. code:: bash 149 | 150 | svgis draw --crs utm in.shp -o out.svg 151 | svgis draw -j utm in2.shp -o out2.svg 152 | 153 | When the local argument is given, SVGIS will generate a Transverse 154 | Mercator projection that centers on the input bounding box. This 155 | generally gives good results for an region roughly the size of a large 156 | urban area. 157 | 158 | .. code:: bash 159 | 160 | svgis draw --crs local input.shp -o out.svg 161 | svgis draw -j local input.shp -o out.svg 162 | 163 | If, for some reason you want to draw an SVG in lat-long coordinates, 164 | use the ``file`` keyword to force the projection of the first passed file: 165 | 166 | .. code:: bash 167 | 168 | svgis draw --crs file input.shp -o out.svg 169 | svgis draw -j file one.shp two.geojson -o out.svg 170 | 171 | 172 | To properly convert the input coordinate, svgis needs to know your input 173 | projection. If the input file doesn't specify an internal projection, 174 | SVGIS will assume that the coordinates are given in 175 | `WGS84 `__. 176 | 177 | (The shorthand option for ``--crs`` is ``-j`` as in ject.) 178 | 179 | style 180 | ^^^^^ 181 | 182 | The style parameter takes either a CSS file or a CSS string. 183 | 184 | .. code:: bash 185 | 186 | svgis draw --style style.css in.shp -o out.svg 187 | svgis draw --style "line { stroke: green; }" in.shp -o out.svg 188 | 189 | SVGIS adds a ``polygon`` class to paths that drawn to represent 190 | multi-part polygons (polygons with holes). 191 | 192 | This argument can be provided multiple times. 193 | 194 | (The shorthand option for ``--style`` is ``-c`` as in CSS.) 195 | 196 | padding 197 | ^^^^^^^ 198 | 199 | Adds a padding around the output image. Accepts an integer in svg units. 200 | 201 | .. code:: bash 202 | 203 | svgis draw --padding 100 in.shp -o out.svg 204 | 205 | no-viewbox 206 | ^^^^^^^^^^ 207 | 208 | By default, SVGIS uses a viewbox. If you have a problem opening the 209 | created svg file in your drawing program (e.g. Adobe Illustrator), try 210 | the '--no-viewbox' option, which will create an svg where the contents 211 | are translated into the frame. 212 | 213 | .. code:: bash 214 | 215 | svgis draw --no-viewbox in.shp -o out.svg 216 | svgis draw -x in.shp -o out.svg 217 | 218 | class-fields and id-field 219 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 220 | 221 | Use these options to specify fields in the source geodata file to use to 222 | determine the class or id attributes of the output SVG features. In the 223 | output fields, whitespace is be replaced with underscores. See :doc:`styles` 224 | for details. 225 | 226 | For example, the `Natural Earth 227 | admin\_0 `__ 228 | file contains nation polygons, and includes ``continent``, 229 | ``income_grp`` and ``name`` fields: 230 | 231 | .. code:: bash 232 | 233 | svgis draw --class-fields continent,income_grp --id-field name \ 234 | ne_110m_admin_0_countries.shp -o out.svg 235 | 236 | The result will include something like: 237 | 238 | .. code:: xml 239 | 240 | 241 | /* Afghanistan */ 242 | /* Angola */ 243 | /* ... */ 244 | /* Zimbabwe */ 245 | 246 | 247 | Note that each layer is always wrapped in a group with ``id`` set to the its name, 248 | and ``class`` set to the names of the its fields. 249 | 250 | The ``id`` (``ne_110m_admin_0_countries``) is repeated as a classes 251 | in each element of a layer. This makes writing CSS that addresses 252 | particular layers easier, given that some implementations of SVG don't 253 | properly implement css rules with ids (e.g. Adobe Illustrator, ImageMagick). 254 | 255 | Note that the ``income\_grp`` field contains values like "5. Low income", 256 | which resultes in a class like ``income_grp_5_Low_income``. Whitespace is replaced 257 | with underscores, periods and number signs (``#``) are removed. Missing values will 258 | be represented with the Pythonic "None". 259 | 260 | .. code:: 261 | 262 | .income_grp_5_Low_income { 263 | fill: teal; 264 | stroke: none; 265 | } 266 | .income_grp_None { 267 | fill: gray; 268 | } 269 | 270 | The ``class-fields`` argument can be provided multiple times. 271 | 272 | data fields 273 | ^^^^^^^^^^^ 274 | 275 | Attributes with the ``data-`` prefix are useful for storing values in front-end Javascript. 276 | Convert specific fields in your geodata to attributes: 277 | 278 | .. code:: bash 279 | 280 | svgis draw --data-fields continent ne_110m_admin_0_countries.shp -o out.svg 281 | 282 | The result will include something like: 283 | 284 | .. code:: xml 285 | 286 | 287 | /* Afghanistan */ 288 | /* Angola */ 289 | /* ... */ 290 | /* Zimbabwe */ 291 | 292 | 293 | 294 | .. _simplify: 295 | 296 | simplify 297 | ^^^^^^^^ 298 | 299 | Requires numpy. Install with ``pip install svgis[simplify]`` to make 300 | this available. 301 | 302 | This option uses the `Visvalingam `_ 303 | algorithm to draw smaller shapes. The ideal setting will depend on source data 304 | and the requirements of the map. 305 | 306 | The ``--simplify`` option takes a number between 1 and 100, which is the 307 | percentage of points to retain. Numbers above 80 usually produce output with 308 | few visible changes. Inputs below 20 often produce highly abstracted results. 309 | 310 | .. code:: bash 311 | 312 | svgis draw --simplify 75 in.shp -o out.svg 313 | svgis draw -s 25 in.shp -o out.svg 314 | 315 | precision 316 | ^^^^^^^^^^^ 317 | 318 | By default, the svg coordinates in drawings are rounded to five decimal places. 319 | The ``precision`` option allows for control over this rounding. Large values 320 | will create clunky and hard-to-use files. Small values (0 is the minimum) will yield 321 | smaller but possibly distorted files. 322 | 323 | .. code:: bash 324 | 325 | svgis draw --precision 10 in.geojson -o out.svg 326 | svgis draw --precision 0 in.geojson -o out.svg 327 | 328 | This will produce output like this (respectively): 329 | 330 | .. code:: xml 331 | 332 | 333 | 334 | .. code:: xml 335 | 336 | 337 | 338 | 339 | no-inline 340 | ^^^^^^^^^ 341 | 342 | Run with this option to prevent svgis from adding style attributes 343 | onto each element. This will be quicker than the default, which 344 | requires parsing the CSS and examining each element. Some SVG clients 345 | (Adobe Illustrator) prefer inline styles. 346 | 347 | Without ``--no-inline`` (or with ``--inline``), SVG elements will look like: 348 | 349 | .. code:: xml 350 | 351 | 352 | 353 | .. code:: bash 354 | 355 | svgis draw --style example.css --inline in.geojson -o out.svg 356 | svgis draw -s example.css -l in.geojson -o out.svg 357 | 358 | clip/no-clip 359 | ^^^^^^^^^^^^ 360 | 361 | Install with ``pip install svgis[clip]`` to make this available. This 362 | requires additional libraries, see the `shapely installation 363 | notes `__. 364 | 365 | When installed with the clip option, SVGIS will try to clip output 366 | shapes to just outside of the bounding box. Use this option to disable 367 | that behavior. 368 | 369 | :: 370 | svgis draw --bounds -170 40 -160 30 --no-clip in.shp --o out.shp 371 | svgis draw -b 125 30 150 50 -n in.shp --o out.shp 372 | 373 | SVGIS always omits features that fall completely outside the bounding 374 | box, clipping edits the shapes so that the parts that lie outside of 375 | the bounding box are omitted. 376 | 377 | Clipping won't occur when no bounding box is given. 378 | 379 | 380 | Helpers 381 | ======= 382 | 383 | svgis bounds 384 | ^^^^^^^^^^^^ 385 | 386 | Get the bounds of a layer. The ``--crs`` option will transform the 387 | bounds into the given projection, otherwise the native coordinates 388 | are returned. 389 | 390 | The result is four coordinates in minx, miny, maxx, maxy order: 391 | 392 | :: 393 | 394 | svgis bounds in.shp 395 | -87.8098 41.6444 -87.5209 42.0201 396 | 397 | 398 | This is useful for setting the bounds of a drawing based on the bounds 399 | of a layer: 400 | 401 | :: 402 | 403 | svgis bounds in.shp | 404 | xargs -n 4 svgis draw --crs EPSG:3003 in.shp in2.json in3.shp --bounds > out.svg 405 | 406 | 407 | Or, check what projection SVGIS will generate given a file: 408 | 409 | :: 410 | 411 | svgis bounds in.shp | 412 | xargs -n 4 svgis project -- 413 | 414 | Keep in mind that when converting between projections, ``svgis bounds`` is lazy. 415 | The returned bounding box will cover the geometry, but may include extra space. 416 | 417 | :: 418 | 419 | Usage: svgis bounds [OPTIONS] [LAYER] 420 | 421 | Return the bounds for a given layer. 422 | 423 | Options: 424 | -j, --crs KEYWORD Specify a map projection. Accepts either an EPSG code 425 | (e.g. epsg:4456), a proj4 string, a file containing a 426 | proj4 string, "utm" (use local UTM), "file" (use 427 | existing), "local" (generate a local projection) 428 | --latlon Print bounds in latitude, longitude order 429 | -h, --help Show this message and exit. 430 | 431 | 432 | 433 | svgis graticule 434 | ^^^^^^^^^^^^^^^^ 435 | 436 | Generate a graticule (grid) in a given projection. The output file is in geojson format, with 437 | WGS84 coordinates. 438 | 439 | Specifying a projection will produce a grid in that projection, and the step must reflect 440 | projection units. However, the output file will always be in WGS84 lon/lat coordinates, but 441 | that shouldn't make a difference in it's use. 442 | 443 | For coordinates with negative numbers, use the ``--`` argument separator to prevent the utility 444 | getting confused: 445 | 446 | :: 447 | svgis graticule -o graticule.json -- 16.3 -34.8 32.8 -22.0 448 | 449 | :: 450 | 451 | Usage: svgis graticule [OPTIONS] minx miny maxx maxy 452 | 453 | Generate a GeoJSON containing a graticule. Accepts a bounding box in 454 | longitude and latitude (WGS84). 455 | 456 | Options: 457 | -s, --step FLOAT Step between lines (in projected units) [required] 458 | -j, --crs TEXT Specify a map projection. Accepts either an EPSG code 459 | (e.g. epsg:4456), a proj4 string, a file containing a 460 | proj4 string, "utm" (use local UTM), "local" 461 | (generate a local projection) 462 | -o, --output FILENAME Defaults to stdout. 463 | -h, --help Show this message and exit. 464 | 465 | 466 | svgis scale 467 | ^^^^^^^^^^^ 468 | 469 | Scale all coordinates in an SVG by a given factor. 470 | 471 | :: 472 | 473 | Usage: svgis scale [OPTIONS] [INPUT] [OUTPUT] 474 | 475 | Options: 476 | -f, --scale INTEGER 477 | -h, --help Show this message and exit. 478 | 479 | 480 | svgis style 481 | ^^^^^^^^^^^ 482 | 483 | Add or replace the CSS style in an SVG. 484 | 485 | :: 486 | 487 | Usage: svgis style [OPTIONS] [INPUT] [OUTPUT] 488 | 489 | Options: 490 | -s, --style TEXT Style to append to SVG. Either a valid CSS string, a 491 | file path (must end in '.css'). Use '-' for stdin. 492 | -r, --replace TEXT 493 | -h, --help Show this message and exit. 494 | 495 | 496 | svgis project 497 | ^^^^^^^^^^^^^ 498 | 499 | SVGIS can automatically generate local projections or pick the local UTM projection for input geodata. This utility gives the projection SVGIS would pick for a given boundary box in PROJ4 syntax. The input should be four coordinates: ``minx miny maxx maxy``. 500 | 501 | By default, ``svgis project`` expects WGS84 coordinates. Specify another projection with the ``--crs`` argument. 502 | 503 | The output projection will be a local Transverse Mercator projection. Use ``--method utm`` to return the local UTM projection. 504 | 505 | For coordinates with negative numbers, use the ``--`` argument separator to prevent the utility 506 | getting confused with the ``-`` option flag: 507 | 508 | :: 509 | svgis project -m utm -- 16.3449768409 -34.8191663551 32.830120477 -22.0913127581 510 | +proj=utm +zone=35 +south +datum=WGS84 +units=m +no_defs 511 | 512 | svgis project -m local -- -110.277906 35.450777 513 | +ellps=GRS80 +lat_0 +lat_1=35.450777 +lat_2=35.450777 +lon_0=-111.0 +no_defs +proj=lcc +towgs84 +y_0=0 514 | 515 | :: 516 | 517 | Usage: svgis project [OPTIONS] MINX MINY MAXX MAXY 518 | 519 | Options: 520 | -m, --method [utm|local] Defaults to local 521 | -j, --crs TEXT Projection of the bounding coordinates 522 | -h, --help Show this message and exit. 523 | -------------------------------------------------------------------------------- /src/svgis/svgis.py: -------------------------------------------------------------------------------- 1 | """Draw SVG maps""" 2 | # This file is part of svgis. 3 | # https://github.com/fitnr/svgis 4 | # Licensed under the GNU General Public License v3 (GPLv3) license: 5 | # http://opensource.org/licenses/GPL-3.0 6 | # Copyright (c) 2015-16, 2020, Neil Freeman 7 | import logging 8 | import os.path 9 | import warnings 10 | from collections.abc import Iterable 11 | from functools import partial 12 | 13 | import fiona 14 | import fiona.transform 15 | from pyproj.crs import CRS 16 | 17 | from . import bounding, draw, projection 18 | from . import style as _style 19 | from . import svg, transform, utils 20 | from .errors import SvgisError 21 | 22 | STYLE = ( 23 | 'polyline,line,rect,path,polygon,.polygon{' 24 | 'fill:none;' 25 | 'stroke:#000;' 26 | 'stroke-width:1px;' 27 | 'stroke-linejoin:round;' 28 | '}' 29 | ) 30 | 31 | 32 | warnings.filterwarnings("ignore") 33 | 34 | 35 | def map(layers, bounds=None, scale=None, **kwargs): 36 | """ 37 | Draw a geodata layer to SVG. This is shorthand for creating a :class:`SVGIS` instance 38 | and immediately runnning :class:`SVGIS.compose`. 39 | 40 | Args: 41 | layers (sequence): Input geodata files. 42 | bounds (sequence): (minx, miny, maxx, maxy) 43 | scale (int): Map scale. Larger numbers -> smaller maps 44 | padding (int): Pad around bounds by this much. In projection units. 45 | crs (string): EPSG code, PROJ.4 string, or file containing a PROJ.4 string 46 | clip (bool): If true, clip features output to bounds. 47 | style (Sequence): Path to a css file or a css string. 48 | class_fields (Sequence): A comma-separated string or list of class names to 49 | use the SVG drawing. 50 | id_field (string): Field to use to determine id of each element in the drawing. 51 | inline (bool): If False, do not move CSS declarations into each element. 52 | precision (int): Precision for rounding output coordinates. 53 | simplify (int): Integer between 1 and 99 describing simplification level. 54 | 99: not very much. 1: a lot. 55 | 56 | Returns: 57 | ``str`` containing an entire SVG document. 58 | """ 59 | # pylint: disable=redefined-builtin 60 | scale = (1.0 / scale) if scale else 1.0 61 | bounds = bounding.check(bounds) 62 | 63 | # Try to read style file(s) 64 | styles = ''.join(_style.pick(s) for s in kwargs.pop('style', [])) 65 | 66 | class_fields = set(a for c in kwargs.pop('class_fields', []) for a in c.split(',')) 67 | data_fields = set(a for c in kwargs.pop('data_fields', []) for a in c.split(',')) 68 | 69 | drawing = SVGIS( 70 | layers, 71 | bounds=bounds, 72 | scalar=scale, 73 | crs=kwargs.pop('crs', None), 74 | style=styles, 75 | clip=kwargs.pop('clip', True), 76 | id_field=kwargs.pop('id_field', None), 77 | class_fields=class_fields, 78 | data_fields=data_fields, 79 | simplify=kwargs.pop('simplify', None), 80 | ).compose(**kwargs) 81 | 82 | return drawing 83 | 84 | 85 | class SVGIS: 86 | 87 | """ 88 | Draw geodata files to SVG. 89 | 90 | Args: 91 | files (list): A list of files to draw. 92 | bounds (Sequence): An iterable with four float coordinates in (minx, miny, maxx, maxy) format 93 | crs (dict): A proj-4 like mapping, or a projection method keyword (file, local, utm). 94 | style (string): CSS to add to output file 95 | scalar (int): Map scaling factor (output coordinate are multiplied by this) 96 | style (str): CSS styles 97 | padding (number): Buffer each edge by this many map units. 98 | precision (int): Precision for rounding output coordinates. 99 | simplify (int): Simplification factor (between 1 and 100). 100 | id_field (str): Field in data to use for ID'ing elements. 101 | class_fields (Sequence): Fields in data for added classes to elements. 102 | """ 103 | 104 | # The bounding box in input coordinates. 105 | _unprojected_bounds = None 106 | 107 | # The bounding box in output coordinates, to be determined as we draw. 108 | _projected_bounds = None 109 | 110 | _in_crs, _out_crs = None, None 111 | 112 | clipper = None 113 | simplifier = None 114 | 115 | def __init__(self, files, bounds=None, crs=None, **kwargs): 116 | self.log = logging.getLogger('svgis') 117 | 118 | if isinstance(files, str): 119 | self.files = [files] 120 | elif isinstance(files, Iterable): 121 | self.files = files 122 | else: 123 | raise SvgisError("'files' must be a file name or list of file names") 124 | 125 | self.log.info('starting SVGIS, files: %s', ', '.join(self.files)) 126 | 127 | if bounding.check(bounds): 128 | self._unprojected_bounds = bounds 129 | elif bounds: 130 | self.log.warning("ignoring invalid bounds: %s", bounds) 131 | 132 | # This may return a keyword, which will require more updating. 133 | # If so, will update when files are open. 134 | self._out_crs = kwargs.get('out_crs', crs) 135 | self.log.debug('picked tentative output projection or method: %s', self._out_crs) 136 | 137 | self.scalar = kwargs.pop('scalar', 1) or 1 138 | 139 | self.style = STYLE + (kwargs.pop('style', '') or '') 140 | 141 | self.padding = kwargs.pop('padding', 0) or 0 142 | 143 | self.precision = kwargs.pop('precision', None) 144 | 145 | self.clip = kwargs.pop('clip', True) 146 | 147 | simple = kwargs.pop('simplify', None) 148 | 149 | if simple: 150 | self.simplifier = transform.simplifier(simple) 151 | self.log.debug('Simplifying with a factor of %d', simple) 152 | 153 | self.id_field = kwargs.pop('id_field', None) 154 | 155 | self.class_fields = kwargs.pop('class_fields', []) 156 | self.data_fields = kwargs.pop('data_fields', []) 157 | 158 | def __repr__(self): 159 | return f'SVGIS(files={self.files}, out_crs={self.out_crs})' 160 | 161 | @property 162 | def in_crs(self): 163 | """Return the CRS being used for input geodata.""" 164 | return self._in_crs 165 | 166 | def set_in_crs(self, crs): 167 | """ 168 | Set the CRS to use for input geodata, falling back on the default (WGS84). 169 | """ 170 | if not self.in_crs: 171 | if crs: 172 | self.log.debug('setting input crs to %s', crs) 173 | self._in_crs = projection.pick(crs) 174 | return 175 | 176 | # Assume input CRS is WGS 84 177 | self._in_crs = projection.pick(utils.DEFAULT_GEOID) 178 | self.log.debug('set_in_crs: setting input crs to default %s', self._in_crs) 179 | self.log.warning('set_in_crs: Found no input coordinate system, ' 'assuming WGS84 (long/lat) coordinates.') 180 | 181 | @property 182 | def out_crs(self): 183 | """The output CRS of this drawing""" 184 | if isinstance(self._out_crs, CRS): 185 | return self._out_crs 186 | return None 187 | 188 | def set_out_crs(self, bounds): 189 | '''Set the output CRS, if not yet set.''' 190 | if self.out_crs: 191 | return 192 | 193 | # Determine projection transformation: 194 | # either use something passed in, a non latlong layer projection, 195 | # the local UTM, or customize local TM 196 | self.log.debug('set_out_crs: out crs: %s', self._out_crs) 197 | self.log.debug('set_out_crs: in crs: %s', self.in_crs) 198 | self.log.debug('set_out_crs: bounds: %s', bounds) 199 | self._out_crs = projection.pick(self._out_crs, bounds, self.in_crs) 200 | self.log.debug('set_out_crs: Set output crs to %s', self.out_crs) 201 | 202 | @property 203 | def unprojected_bounds(self): 204 | '''Returns None if projected bounds aren't set''' 205 | if self._unprojected_bounds: 206 | return self._unprojected_bounds 207 | return None 208 | 209 | @property 210 | def projected_bounds(self): 211 | '''Returns None if projected bounds aren't (yet) set''' 212 | if self._projected_bounds: 213 | return self._projected_bounds 214 | return None 215 | 216 | def update_projected_bounds(self, in_crs, out_crs, bounds, padding=None): 217 | """ 218 | Extend projected_bounds bbox with self.padding. 219 | 220 | Args: 221 | in_crs (dict): CRS of bounds. 222 | out_crs (dict) desired output CRS. 223 | bounds (tuple): bounding box. 224 | 225 | Returns: 226 | ``tuple`` bounding box in out_crs coordinates. 227 | """ 228 | # This may happen many times if we were passed bounds, but it's a cheap operation. 229 | self.log.debug('update_projected_bounds: in_crs: %s', in_crs) 230 | self.log.debug('update_projected_bounds: out_crs: %s', out_crs) 231 | projected = bounding.transform(bounds, in_crs=in_crs, out_crs=out_crs) 232 | self._projected_bounds = bounding.pad(projected, padding or 0) 233 | self.log.debug('update_projected_bounds: new bounds: %s', self._projected_bounds) 234 | return self._projected_bounds 235 | 236 | def _get_clipper(self, layer_bounds, out_bounds, scalar=None): 237 | """ 238 | Get a clipping function for the given input crs and bounds. 239 | 240 | Args: 241 | layer_bounds (tuple): The bounds of the layer. 242 | out_bounds (tuple): The desired output bounds (in layer coordinates). 243 | scalar (float): Map scale. 244 | 245 | Returns: 246 | ``None`` if layer_bounds are inside out_bounds or clipping is off. 247 | """ 248 | if not self.clip or bounding.covers(out_bounds, layer_bounds): 249 | return None 250 | 251 | scalar = scalar or self.scalar 252 | 253 | if not self.clipper: 254 | padded_bounds = bounding.pad(self.projected_bounds, 1000) 255 | self.clipper = transform.clipper([c * scalar for c in padded_bounds]) 256 | 257 | return self.clipper 258 | 259 | def _reprojector(self, in_crs): 260 | '''Return a reprojection transform from in_crs to self.out_crs.''' 261 | if self.out_crs != in_crs: 262 | self.log.info('set up reprojection') 263 | self.log.debug(' input crs: %s', in_crs) 264 | self.log.debug(' output crs: %s', self.out_crs) 265 | return partial(fiona.transform.transform_geom, in_crs, self.out_crs.to_dict()) 266 | 267 | return None 268 | 269 | def _prepare_layer(self, layer, filename, bounds, scalar, **kwargs): 270 | """ 271 | Prepare the keyword args for drawing a layer. 272 | 273 | Args: 274 | layer (fiona.layer): input layer 275 | filename (str): Name of file, used for group id attribute. 276 | bounds (tuple): Bounding box (in layer.crs). 277 | scalar (int): Map scale 278 | simplifier (function): simplication function 279 | id_field (str): Field to use for element id attribute. 280 | class_fields (list): Fields to use for element class attribute. 281 | 282 | Returns: 283 | ``dict`` Arguments for ``self._feature`` 284 | """ 285 | result = { 286 | 'transforms': [ 287 | self._reprojector(layer.crs), 288 | partial(transform.scale_geom, factor=scalar), 289 | # Get clipping function based on a slightly extended version of _projected_bounds. 290 | self._get_clipper(layer.bounds, bounds, scalar=scalar), 291 | self.simplifier, 292 | ] 293 | } 294 | 295 | # Correct for OGR's lack of creativity for GeoJSONs. 296 | if layer.name == 'OGRGeoJSON': 297 | result['name'] = os.path.splitext(os.path.basename(filename))[0] 298 | else: 299 | result['name'] = layer.name 300 | 301 | # A list of class names to get from layer properties. 302 | class_fields = kwargs.pop('class_fields', None) or self.class_fields 303 | result['classes'] = [x for x in class_fields if x in layer.schema['properties']] 304 | data_fields = kwargs.pop('data_fields', None) or self.data_fields 305 | result['datas'] = [x for x in data_fields if x in layer.schema['properties']] 306 | 307 | # Remove the id field if it doesn't appear in the properties. 308 | id_field = kwargs.pop('id_field', self.id_field) 309 | 310 | result['id_field'] = id_field if id_field in layer.schema['properties'].keys() else None 311 | 312 | result.update(kwargs) 313 | 314 | return result 315 | 316 | def compose_file(self, path, unprojected_bounds=None, **kwargs): 317 | """ 318 | Draw fiona file to an SVG group. 319 | 320 | Args: 321 | path (string): path to a fiona-readable file, or an Apache Commons VFS spec for a zip 322 | or tar archive, e.g. ``zip://path/to/archive.zip/file.shp``. 323 | unprojected_bounds (tuple): (minx, maxx, miny, maxy) in the layer's coordinate system. 324 | 'None' values are OK. "Unprojected" here refers to 325 | the fact that we haven't transformed these bounds yet. 326 | They may well, in fact, be in a projection. 327 | padding (int): Number of map units by which to pad output bounds. 328 | scalar (int): map scale 329 | class_fields (sequence): Fields to turn in the element classes (default: self.class_fields). 330 | id_field (string): Field to use as element ID (default: self.id_field). 331 | 332 | Returns: 333 | A ``dict`` with the keys: ``members``, ``id``, ``class``. 334 | This is ready to be passed to ``svgis.svg.group``. 335 | """ 336 | padding = kwargs.pop('padding', self.padding) 337 | kwargs['scalar'] = kwargs.get('scalar', self.scalar) 338 | unprojected_bounds = unprojected_bounds or self.unprojected_bounds 339 | with fiona.Env(): 340 | self.log.debug('opening %s', path) 341 | with fiona.open(path) as layer: 342 | self.log.info('reading %s', layer.name) 343 | 344 | # Set the input CRS, if not yet set. 345 | self.set_in_crs(layer.crs) 346 | 347 | # When we have passed bounds: 348 | if unprojected_bounds: 349 | self.log.debug("Set the output CRS, if not yet set, using unprojected bounds: %s", unprojected_bounds) 350 | self.set_out_crs(unprojected_bounds) 351 | 352 | # If we haven't set the projected bounds yet, do that. 353 | if not self.projected_bounds: 354 | self.update_projected_bounds(self.in_crs, self.out_crs, unprojected_bounds, padding) 355 | 356 | self.log.debug( 357 | 'Getting projected bounds %s (%s) in layer crs (%s)', 358 | self.projected_bounds, 359 | self.out_crs, 360 | layer.crs, 361 | ) 362 | bounds = bounding.transform(self.projected_bounds, in_crs=self.out_crs, out_crs=layer.crs) 363 | 364 | # When we have no passed bounds: 365 | else: 366 | self.log.debug("Set the output CRS, if not yet set, using this layer's bounds.") 367 | self.set_out_crs(layer.bounds) 368 | 369 | # Extend projection_bounds 370 | self.update_projected_bounds(layer.crs, self.out_crs, layer.bounds, padding) 371 | bounds = layer.bounds 372 | 373 | kwargs = self._prepare_layer(layer, path, bounds, **kwargs) 374 | group = tuple(self.feature(f, **kwargs) for _, f in layer.items(bbox=bounds)) 375 | 376 | return { 377 | 'members': group, 378 | 'id': kwargs['name'], 379 | 'class': ' '.join(_style.sanitize(c) for c in layer.schema['properties'].keys()), 380 | } 381 | 382 | def feature(self, feature, transforms, classes, datas=None, **kwargs): 383 | """ 384 | Draw a single feature. 385 | 386 | Args: 387 | feature (dict): A GeoJSON like feature dict produced by Fiona. 388 | transforms (list): Functions to apply to the geometry. 389 | classes (list): Names (unsanitized) of fields to apply as classes in the output element. 390 | datas (dict): key-value pairs to add as data-KEY="value" elements in the output element. 391 | precision (int): rounding precision for coordinates. 392 | id_field (str): Field to use as id of the output element. 393 | name (str): layer name (usually basename of the input file). 394 | 395 | Returns: 396 | ``str`` 397 | """ 398 | name = kwargs.pop('name', None) 399 | geom = feature.get('geometry') 400 | precision = kwargs.pop('precision', self.precision) 401 | datas = datas or {} 402 | fid = feature['properties'].get(kwargs.get('id_field'), feature.get('id', '?')) 403 | 404 | try: 405 | # Check if geometry exists (a bit unpythonic, but cleaner errs this way). 406 | if geom is None: 407 | raise SvgisError('NULL geometry') 408 | 409 | # Apply transformations to the geometry. 410 | for t in transforms: 411 | geom = t(geom) if t is not None else geom 412 | 413 | if geom['coordinates'] is None or len(geom['coordinates']) == 0: 414 | self.log.debug( 415 | 'Skipping feature with empty geometry after transformation: "%s" in layer "%s"', fid, name or '?' 416 | ) 417 | return '' 418 | 419 | except SvgisError as e: 420 | self.log.warning( 421 | 'error transforming feature %s of %s: %s', kwargs.get('id', feature.get('id', '?')), name, e 422 | ) 423 | return '' 424 | 425 | # Set up the element's properties. 426 | drawargs = _style.construct_datas(datas, feature['properties']) 427 | 428 | classes = _style.construct_classes(classes, feature['properties']) 429 | 430 | # Add the layer name to the class list. 431 | if name: 432 | classes.insert(0, _style.sanitize(name)) 433 | 434 | drawargs['class'] = ' '.join(classes) 435 | 436 | if 'id_field' in kwargs and kwargs['id_field'] in feature['properties']: 437 | drawargs['id'] = _style.sanitize(fid) 438 | 439 | try: 440 | # Draw the geometry. 441 | return draw.geometry(geom, precision=precision, **drawargs) 442 | 443 | except SvgisError as e: 444 | self.log.warning('unable to draw feature %s of %s: %s', fid, name or '?', e) 445 | return '' 446 | 447 | def compose(self, bounds=None, style=None, viewbox=True, inline=True, **kwargs): 448 | """ 449 | Draw files to svg. 450 | 451 | Args: 452 | bounds (Sequence): Map bounding box in WGS84 (longlat) coordinates. 453 | Defaults to map data bounds. 454 | scalar (int): factor by which to scale the data, generally a small number (1/map scale). 455 | style (str): CSS to append to parent object CSS. 456 | viewbox (bool): If True, draw SVG with a viewbox. If False, translate coordinates 457 | to the frame. Defaults to True. 458 | inline (bool): If False, do not add CSS style attributes to each element. 459 | padding (int): Number of (projected) units to pad bounds by. 460 | precision (int): Precision for rounding output coordinates. 461 | 462 | Returns: 463 | ``str`` containing an entire SVG document. 464 | """ 465 | # Set up arguments 466 | scalar = kwargs.pop('scalar', self.scalar) 467 | bounds = bounding.check(bounds) or self.unprojected_bounds 468 | 469 | # Draw files 470 | members = [svg.group(**self.compose_file(f, bounds, scalar=scalar, **kwargs)) for f in self.files] 471 | 472 | self.log.info('compose(): bounds = %s', bounds) 473 | self.log.info('compose(): style = %s', (style or '')[:25]) 474 | self.log.info('compose(): viewbox = %s', viewbox) 475 | drawing = self.draw(members, scalar, kwargs.get('precision'), style=style, viewbox=viewbox, inline=inline) 476 | 477 | # Always reset projected bounds. 478 | self._projected_bounds = None 479 | 480 | return drawing 481 | 482 | def draw(self, members, scalar=None, precision=None, style=None, **kwargs): 483 | """ 484 | Combine drawn layers into an SVG drawing. 485 | 486 | Args: 487 | members (list): unicode representations of SVG groups. 488 | scalar (int): factor by which to scale the data, generally a small number (1/map scale). 489 | style (str): CSS to append to parent object CSS. 490 | viewbox (bool): If True, draw SVG with a viewbox. If False, translate coordinates to 491 | the frame. Defaults to True. 492 | inline (bool): If True, try to run CSS into each element. 493 | 494 | Returns: 495 | ``str`` containing an entire SVG document. 496 | """ 497 | scalar = scalar or self.scalar 498 | precision = precision or self.precision 499 | style = style or self.style 500 | transform_attrib = 'scale(1,-1)' 501 | 502 | try: 503 | if any((utils.isinf(b) for b in self._projected_bounds)): 504 | self.log.warning('Drawing has infinite bounds, consider changing projection or bounding box.') 505 | 506 | dims = [float(b or 0.0) * scalar for b in self.projected_bounds] 507 | except TypeError: 508 | self.log.warning(r'Unable to find bounds, map is probably empty ¯\_(ツ)_/¯') 509 | dims = 0, 0, 0, 0 510 | 511 | # width and height 512 | size = [dims[2] - dims[0], dims[3] - dims[1]] 513 | 514 | self.log.debug('Size: %f x %f', *size) 515 | 516 | if kwargs.pop('viewbox', True): 517 | viewbox = [dims[0], -dims[3]] + size 518 | self.log.debug('drawing with viewbox') 519 | else: 520 | viewbox = None 521 | transform_attrib += f' translate({-dims[0]},{-dims[3]})' 522 | self.log.debug('translating contents to fit') 523 | 524 | # Create container and then SVG 525 | container = svg.group(members, transform=transform_attrib) 526 | drawing = svg.drawing(size, [container], style=style, precision=precision, viewbox=viewbox) 527 | 528 | if kwargs.pop('inline', False): 529 | self.log.info('inlining styles') 530 | drawing = _style.inline(drawing) 531 | 532 | return drawing 533 | --------------------------------------------------------------------------------