├── carto ├── __init__.py └── print.py ├── requirements.txt ├── MANIFEST.in ├── CONTRIBUTORS ├── NEWS.md ├── setup.py ├── CONTRIBUTING.md ├── .gitignore ├── LICENSE ├── README.md └── bin └── carto-print /carto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==6.2.0 2 | future==0.16.0 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Alberto Romeu 2 | Roman Jiménez 3 | Sergio Conde 4 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | May-31-2019: version 0.0.8 2 | - raise errors 3 | 4 | May-29-2019: version 0.0.6 5 | - fix binary 6 | 7 | May-28-2019: version 0.0.5 8 | - standardize bounding_box coordinate order 9 | 10 | Feb-20-2019: version 0.0.4 11 | - command line support 12 | 13 | Nov-14-2018: version 0.0.3 14 | - Support for custom CARTO installations 15 | 16 | Oct-29-2018: version 0.0.2 17 | - Support for RGBA and CMYK color modes 18 | 19 | Oct-25-2018: version 0.0.1 20 | - First version 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | try: 5 | with open('requirements.txt') as f: 6 | required = f.read().splitlines() 7 | except: 8 | required = ['Pillow==5.3.0', 'future==0.16.0'] 9 | 10 | setup(name="carto-print", 11 | author="Alberto Romeu", 12 | author_email="alrocar@carto.com", 13 | description="A module to export images from CARTO named maps", 14 | version="0.0.8", 15 | url="https://github.com/CartoDB/carto-print", 16 | install_requires=required, 17 | packages=["carto"], 18 | scripts=["bin/carto-print"]) 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Submitting Contributions 2 | 3 | Contributions are totally welcome. However, contributors must sign a Contributor License Agreement (CLA) before making a submission. [Learn more here.](https://carto.com/contributing) 4 | 5 | ## Release process 6 | 7 | 1. Update version number and information at `setup.py` and `NEWS.md`. 8 | 2. You must be maintainer at [carto-print pypi repo](https://pypi.python.org/pypi/carto-print/). 9 | 3. Prepare a `~/.pypirc` file: 10 | 11 | ``` 12 | [distutils] 13 | index-servers = 14 | pypi 15 | pypitest 16 | 17 | [pypi] 18 | username=your_username 19 | password=your_password 20 | 21 | [pypitest] 22 | username=your_username 23 | password=your_password 24 | ``` 25 | 26 | 4. Upload the package to the test repository: `python setup.py sdist upload -r pypitest`. 27 | 5. Install it in a new environment: `pip install --index-url=https://test.pypi.org/simple --extra-index-url=https://pypi.org/simple carto-print`. 28 | 6. Test it. 29 | 7. Release it: `python setup.py sdist upload -r pypi`. 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | 63 | *.swp 64 | secret.py 65 | env 66 | 67 | .idea/* 68 | .vscode/ 69 | .DS_Store 70 | *variables.sh 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, CartoDB 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | carto-print 2 | =========== 3 | 4 | A Python module to export images at any resolution, size and bounding box from a CARTO named map: 5 | 6 | * [Maps API](https://carto.com/docs/carto-engine/maps-api) 7 | 8 | Installation 9 | ============ 10 | 11 | You can install carto-print by cloning this repository or by using 12 | [Pip](http://pypi.python.org/pypi/pip): 13 | 14 | pip install carto-print 15 | 16 | If you want to use the development version, you can install directly from github: 17 | 18 | pip install -e git+git://github.com/CartoDB/carto-print.git#egg=carto 19 | 20 | If using, the development version, you might want to install dependencies as well: 21 | 22 | pip install -r requirements.txt 23 | 24 | *Only tested in Python 3* 25 | 26 | Usage Example 27 | ============= 28 | 29 | Command Line Tool 30 | ----------------- 31 | 32 | ``` 33 | usage: carto-print [-h] [--api-key API_KEY] [--dpi DPI] [--img-format FORMAT] 34 | [--output-dir DIR] [--server-url URL] 35 | USER MAP_TPL WIDTH_CM HEIGHT_CM ZOOM BOUNDING_BOX 36 | 37 | Exports images at any resolution, size and bounding box from a CARTO named 38 | map. 39 | 40 | positional arguments: 41 | USER CARTO user name 42 | MAP_TPL map template or named map name 43 | WIDTH_CM width in cm 44 | HEIGHT_CM height in cm 45 | ZOOM zoom level 46 | BOUNDING_BOX bounding box: south,west,north,east (min lon, min lat, 47 | max lon, max lat) 48 | 49 | optional arguments: 50 | -h, --help show this help message and exit 51 | --api-key API_KEY CARTO api_key (default: default_public) 52 | --dpi DPI output image DPI (default: 72) 53 | --img-format FORMAT output image format: RGBA, CMYK (default: RGBA) 54 | --output-dir DIR output directory (default: current one) 55 | --server-url URL server base URL, should contain the token {username} 56 | (default: https://{username}.carto.com) 57 | ``` 58 | 59 | In this example we are exporting a 300 dpi and 30x20 cm image of the [Paris flood map](https://aromeu.carto.com/builder/87c5667f-3eb5-4a19-9300-b39a2d1970d1/embed): 60 | 61 | ``` 62 | carto-print aromeu tpl_87c5667f_3eb5_4a19_9300_b39a2d1970d1 30 20 12 1.956253,48.711127,2.835159,49.012429 --dpi 300 --img-format CMYK --output-dir /tmp 63 | ``` 64 | 65 | For bounding boxes starting with negative coordinates, use this format: 66 | 67 | ``` 68 | carto-print aromeu tpl_87c5667f_3eb5_4a19_9300_b39a2d1970d1 30 20 12 " -1.956253,48.711127,2.835159,49.012429" --dpi 300 --img-format CMYK --output-dir /tmp 69 | ``` 70 | 71 | 72 | Library 73 | ------- 74 | 75 | The previous example calling directly the library would be as follows: 76 | 77 | ```python 78 | from carto.print import Printer 79 | 80 | printer = Printer('aromeu', 'tpl_87c5667f_3eb5_4a19_9300_b39a2d1970d1', 'default_public', 30, 20, 12, '1.956253,48.711127,2.835159,49.012429', 300, 'CMYK') 81 | printer.export('/tmp') 82 | ``` 83 | 84 | Where the signature of the `Printer` constructor is as follows: 85 | 86 | ```python 87 | Printer(CARTO_USER_NAME, MAP_ID, CARTO_API_KEY, WIDTH_CM, HEIGHT_CM, ZOOM_LEVEL, BOUNDING_BOX, DPI, IMAGE_FORMAT) 88 | ``` 89 | 90 | Where `IMAGE_FORMAT` is one of `RGBA` or `CMYK` 91 | 92 | 93 | Known Issues 94 | ============ 95 | 96 | Some exported images may not represent exactly what you see in the map for the given zoom level when you ask for a resolution different than 72DPI. The reason is the static maps API returns images at 72DPI so to achieve a bigger resolution the library requests for bigger images to re-scale. 97 | 98 | For this reason, specially labels and point size, line width, may be affected. If that's the case you should be able to "design your map for printing" by increaing the text, points and lines sizes. 99 | 100 | Having said that, some of the known issues are: 101 | 102 | - Google Maps basemaps cannot be rendered 103 | -------------------------------------------------------------------------------- /bin/carto-print: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import argparse 5 | 6 | from carto.print import IMAGE_MODES, DEFAULT_SERVER_URL, Printer 7 | 8 | 9 | DEFAULT_API_KEY = 'default_public' 10 | DEFAULT_DPI = 72 11 | 12 | 13 | def bbox_check(bbox): 14 | bbox_pts = bbox.split(',') 15 | 16 | # Check that we have 4 values 17 | if len(bbox_pts) != 4: 18 | raise argparse.ArgumentTypeError('Bounding box must have 4 points.') 19 | 20 | # Check that the values are floats 21 | try: 22 | min_lon, min_lat, max_lon, max_lat = map(float, bbox_pts) 23 | except ValueError: 24 | raise argparse.ArgumentTypeError('Bounding box points must be floats.') 25 | 26 | # Check that the points are in range 27 | if not -90 <= min_lat <= 90: 28 | raise argparse.ArgumentTypeError('Bounding box south (min lat) point must be in the [-90,90] range.') 29 | 30 | if not -90 <= max_lat <= 90: 31 | raise argparse.ArgumentTypeError('Bounding box north (max lat) point must be in the [-90,90] range.') 32 | 33 | if not -180 <= min_lon <= 180: 34 | raise argparse.ArgumentTypeError('Bounding box west (min lon) point must be in the [-180,180] range.') 35 | 36 | if not -180 <= max_lon <= 180: 37 | raise argparse.ArgumentTypeError('Bounding box east (max lon) point must be in the [-180,180] range.') 38 | 39 | # Check that max are greater than mins 40 | if min_lat >= max_lat: 41 | raise argparse.ArgumentTypeError('Bounding box north (max lat) point must be greater than the south (min lat) one.') 42 | 43 | if min_lon >= max_lon: 44 | raise argparse.ArgumentTypeError('Bounding box east (max lon) point must be greater than the west (min lon) one.') 45 | 46 | return bbox 47 | 48 | 49 | def writable_dir(directory): 50 | if not os.access(directory, os.W_OK): 51 | raise argparse.ArgumentTypeError('Output directory must be writable by the user.') 52 | 53 | return directory 54 | 55 | 56 | parser = argparse.ArgumentParser( 57 | description='Exports images at any resolution, size and bounding box from a CARTO named map.' 58 | ) 59 | 60 | parser.add_argument('user', metavar='USER', type=str, 61 | help='CARTO user name') 62 | 63 | parser.add_argument('map_tpl', metavar='MAP_TPL', type=str, 64 | help='map template or named map name') 65 | 66 | parser.add_argument('width', metavar='WIDTH_CM', type=int, 67 | help='width in cm') 68 | 69 | parser.add_argument('height', metavar='HEIGHT_CM', type=int, 70 | help='height in cm') 71 | 72 | parser.add_argument('zoom', metavar='ZOOM', type=int, 73 | help='zoom level') 74 | 75 | parser.add_argument('bbox', metavar='BOUNDING_BOX', type=bbox_check, 76 | help='bounding box: west,north,east,south (min lon, min lat, max lon, max lat)') 77 | 78 | parser.add_argument('--api-key', metavar='API_KEY', type=str, default=DEFAULT_API_KEY, 79 | help='CARTO api_key (default: {})'.format(DEFAULT_API_KEY)) 80 | 81 | parser.add_argument('--dpi', metavar='DPI', type=int, default=DEFAULT_DPI, 82 | help='output image DPI (default: {})'.format(DEFAULT_DPI)) 83 | 84 | parser.add_argument('--img-format', metavar='FORMAT', type=str, default=IMAGE_MODES[0], choices=IMAGE_MODES, 85 | help='output image format: {} (default: {})'.format(', '.join(IMAGE_MODES), IMAGE_MODES[0])) 86 | 87 | parser.add_argument('--output-dir', metavar='DIR', type=writable_dir, default=os.getcwd(), 88 | help='output directory (default: current one)') 89 | 90 | parser.add_argument('--server-url', metavar='URL', type=str, default=DEFAULT_SERVER_URL, 91 | help='server base URL, should contain the token {{username}} (default: {})'.format(DEFAULT_SERVER_URL)) 92 | 93 | args = parser.parse_args() 94 | 95 | printer = Printer(args.user, args.map_tpl, args.api_key, 96 | args.width, args.height, args.zoom, args.bbox, 97 | args.dpi, args.img_format, args.server_url) 98 | 99 | print("Exporting...") 100 | print("Exported! Output file: {}".format(printer.export(args.output_dir))) 101 | -------------------------------------------------------------------------------- /carto/print.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import time 4 | 5 | from io import BytesIO 6 | from PIL import Image 7 | from urllib.request import urlopen 8 | from future.standard_library import install_aliases 9 | install_aliases() 10 | 11 | DEFAULT_TILE_SIZE = 256 12 | EARTH_RADIUS = 6378137 13 | NUM_RETRIES = 5 14 | SLEEP_TIME = 2 15 | 16 | ONE_DPI = 0.393701 17 | DEFAULT_RATIO = 3 18 | DEFAULT_PRINT_TILE_SIZE = 2048 19 | MAX_TILE_SIZE = 8192 20 | DEFAULT_DPI = 72.0 21 | DEFAULT_SERVER_URL = 'https://{username}.carto.com' 22 | IMAGE_MODES = ['RGBA', 'CMYK'] 23 | 24 | 25 | def latlon_2_pixels(lat, lon, z): 26 | initialResolution = 2 * math.pi * EARTH_RADIUS / DEFAULT_TILE_SIZE 27 | originShift = 2 * math.pi * EARTH_RADIUS / 2.0 28 | mx = lon * originShift / 180.0 29 | my = math.log(math.tan((90 + lat) * math.pi / 360.0)) / (math.pi / 180.0) 30 | my = my * originShift / 180.0 31 | res = initialResolution / (2**z) 32 | px = (mx + originShift) / res 33 | py = (my + originShift) / res 34 | 35 | return px, py 36 | 37 | 38 | def pixels_2_latlon(py, px, z): 39 | initialResolution = 2 * math.pi * EARTH_RADIUS / DEFAULT_TILE_SIZE 40 | originShift = 2 * math.pi * EARTH_RADIUS / 2.0 41 | res = initialResolution / (2**z) 42 | mx = px * res - originShift 43 | my = py * res - originShift 44 | lon = (mx / originShift) * 180.0 45 | lat = (my / originShift) * 180.0 46 | lat = 180 / math.pi * (2 * math.atan(math.exp(lat * math.pi / 180.0)) - math.pi / 2.0) 47 | 48 | return lat, lon 49 | 50 | 51 | class Printer(object): 52 | def __init__(self, username, map_id, api_key, width_cm, height_cm, 53 | zoom_level, bounds, dpi, mode='RGBA', 54 | server_url=DEFAULT_SERVER_URL): 55 | self.username = username 56 | self.api_key = api_key 57 | self.map_id = map_id 58 | self.width = width_cm 59 | self.height = height_cm 60 | self.zoom = zoom_level 61 | self.bounds = self.create_bounds(bounds) 62 | self.dpi = dpi 63 | self.map_id = map_id 64 | self.filename = self.generate_filename() 65 | self.mode = mode 66 | self.server_url = server_url 67 | 68 | self.validate_mode() 69 | 70 | def export(self, directory): 71 | PIXELS_PER_CM = ONE_DPI * self.dpi 72 | 73 | x_actual_pixels = int(self.width * PIXELS_PER_CM) 74 | y_actual_pixels = int(self.height * PIXELS_PER_CM) 75 | 76 | max_y = self.bounds['south'] 77 | min_y = self.bounds['north'] 78 | max_x = self.bounds['west'] 79 | min_x = self.bounds['east'] 80 | 81 | x_degrees = abs(max_x - min_x) 82 | y_degrees = abs(max_y - min_y) 83 | 84 | degrees_per_pixel = 360.0 / DEFAULT_TILE_SIZE / (pow(2, self.zoom)) 85 | 86 | x_pixels = x_degrees / degrees_per_pixel 87 | y_pixels = y_degrees / degrees_per_pixel 88 | 89 | ratio = min(math.ceil(y_actual_pixels / y_pixels), math.ceil(x_actual_pixels / x_pixels)) 90 | 91 | zoom_ratio = int(math.floor(math.log(int(ratio), 2))) 92 | TILE_SIZE = DEFAULT_TILE_SIZE * pow(2, zoom_ratio) 93 | 94 | if TILE_SIZE > MAX_TILE_SIZE: 95 | TILE_SIZE = MAX_TILE_SIZE 96 | z = self.zoom + zoom_ratio 97 | else: 98 | z = self.zoom + zoom_ratio 99 | 100 | lon = max_x - ((max_x - min_x) / 2) 101 | lat = max_y - ((max_y - min_y) / 2) 102 | 103 | num_tiles_x = int(x_actual_pixels / TILE_SIZE) 104 | num_tiles_y = int(y_actual_pixels / TILE_SIZE) 105 | 106 | dpi_ratio = 1 107 | tile_ratio = 1 108 | degrees_per_pixel = dpi_ratio * tile_ratio * 360.0 / TILE_SIZE / (pow(2, z)) 109 | px, py = latlon_2_pixels(lat, lon, z) 110 | 111 | result = Image.new(self.mode, (x_actual_pixels, y_actual_pixels)) 112 | 113 | px -= x_actual_pixels / 2.0 - TILE_SIZE / 2.0 114 | py += y_actual_pixels / 2.0 - TILE_SIZE / 2.0 115 | 116 | i = 0 117 | num_tiles_x += 1 118 | num_tiles_y += 1 119 | 120 | for x in range(num_tiles_x): 121 | for y in range(num_tiles_y): 122 | i += 1 123 | u_py = py - y * TILE_SIZE 124 | u_px = px + x * TILE_SIZE 125 | n_lat, n_lon = pixels_2_latlon(u_py, u_px, z) 126 | url = self.prepare_url(TILE_SIZE, n_lon, n_lat, z) 127 | file_s = self.requestImage(url) 128 | image1 = Image.open(file_s) 129 | result.paste(im=image1, box=(x * TILE_SIZE, y * TILE_SIZE)) 130 | 131 | path = '{directory}/{filename}.{format}'.format(directory=directory, filename=self.filename, format=self.get_format()) 132 | result.save(path, dpi=(self.dpi, self.dpi), quality=95) 133 | 134 | return path 135 | 136 | def requestImage(self, url): 137 | attempt = 1 138 | while attempt <= NUM_RETRIES: 139 | try: 140 | file_s = BytesIO(urlopen(url).read()) 141 | Image.open(file_s) 142 | return file_s 143 | except Exception as e: 144 | time.sleep(SLEEP_TIME) 145 | attempt += 1 146 | if attempt >= NUM_RETRIES: 147 | raise e 148 | 149 | def create_bounds(self, bounds): 150 | if bounds is None: 151 | return None 152 | 153 | if isinstance(bounds, dict): 154 | return { 155 | 'east': float(bounds['ne'][1]), 156 | 'west': float(bounds['sw'][1]), 157 | 'south': float(bounds['sw'][0]), 158 | 'north': float(bounds['ne'][0]) 159 | } 160 | else: 161 | return { 162 | 'east': float(bounds.split(',')[0]), 163 | 'west': float(bounds.split(',')[2]), 164 | 'south': float(bounds.split(',')[3]), 165 | 'north': float(bounds.split(',')[1]) 166 | } 167 | 168 | def prepare_url(self, tile_size, lon, lat, zoom): 169 | return (self.server_url + '/api/v1/map/static/named/{template}/{tile_size}/{tile_size}.png?zoom={zoom}&lat={lat}&lon={lon}&api_key={api_key}').format(username=self.username, template=self.map_id, tile_size=tile_size, zoom=zoom, lat=lat, lon=lon, api_key=self.api_key) 170 | 171 | def generate_filename(self): 172 | return '{username}_{map_id}_{date}'.format(username=self.username, map_id=self.sanitize(self.map_id), date=datetime.datetime.now().strftime("%Y%m%d%H%M%S")) 173 | 174 | def sanitize(self, anything): 175 | return '_'.join(anything.split('-')).strip() 176 | 177 | def validate_mode(self): 178 | if self.mode not in IMAGE_MODES: 179 | raise Exception('mode not supported') 180 | 181 | def get_format(self): 182 | if self.mode == 'RGBA': 183 | return 'png' 184 | else: 185 | return 'jpg' 186 | --------------------------------------------------------------------------------