# -*- coding: utf-8 -*- # ocitysmap, city map and street index generator from OpenStreetMap data # Copyright (C) 2010 David Decotigny # Copyright (C) 2010 Frédéric Lehobey # Copyright (C) 2010 Pierre Mauduit # Copyright (C) 2010 David Mentré # Copyright (C) 2010 Maxime Petazzoni # Copyright (C) 2010 Thomas Petazzoni # Copyright (C) 2010 Gaël Utard # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import cairo import logging import mapnik import math import os import grid import map_canvas import shapes l = logging.getLogger('ocitysmap') class Renderer: """ The job of an OCitySMap layout renderer is to lay out the resulting map and render it from a given rendering configuration. """ # Portrait paper sizes in milimeters PAPER_SIZES = [('A5', 148, 210), ('A4', 210, 297), ('A3', 297, 420), ('A2', 420, 594), ('A1', 594, 841), ('A0', 841, 1189), ('US letter', 216, 279), ('100x75cm', 750, 1000), ('80x60cm', 600, 800), ('60x45cm', 450, 600), ('40x30cm', 300, 400), ('60x60cm', 600, 600), ('50x50cm', 500, 500), ('40x40cm', 400, 400), ('Best fit', 0, 0), ] # The PRINT_SAFE_MARGIN_PT is a small margin we leave on all page borders # to ease printing as printers often eat up margins with misaligned paper, # etc. PRINT_SAFE_MARGIN_PT = 15 GRID_LEGEND_MARGIN_RATIO = .02 # The DEFAULT_KM_IN_MM represents the minimum acceptable size in milimeters # on the rendered map of a kilometer DEFAULT_KM_IN_MM = 80 def __init__(self, rc, tmpdir): self.rc = rc self.tmpdir = tmpdir self.canvas = None self.grid = None # Switch to landscape mode if the geographic bounding box is wider than # tall. geo_height_m, geo_width_m = rc.bounding_box.spheric_sizes() if geo_width_m > geo_height_m: self.rc.paper_width_mm, self.rc.paper_height_mm = \ self.rc.paper_height_mm, self.rc.paper_width_mm l.debug('Switching to landscape mode (%.1fx%.1fcm)' % self.rc.paper_width_mm/10.0, self.rc.paper_height_mm/10.0) self.paper_width_pt = Renderer.convert_mm_to_pt(self.rc.paper_width_mm) self.paper_height_pt = Renderer.convert_mm_to_pt(self.rc.paper_height_mm) def _create_map_canvas(self, graphical_ratio): self.canvas = map_canvas.MapCanvas(self.rc.stylesheet, self.rc.bounding_box, graphical_ratio) # Add the grid self.grid = grid.Grid(self.canvas.get_actual_bounding_box(), self.rc.rtl) grid_shape = self.grid.generate_shape_file( os.path.join(self.tmpdir, 'grid.shp')) self.canvas.add_shape_file(grid_shape, self.rc.stylesheet.grid_line_color, self.rc.stylesheet.grid_line_alpha, self.rc.stylesheet.grid_line_width) def _draw_rectangle(self, ctx, x, y, width, height, line_width): ctx.save() ctx.set_line_width(line_width) ctx.move_to(x, y) ctx.rel_line_to(0, height) ctx.rel_line_to(width, 0) ctx.rel_line_to(0, - height) ctx.close_path() ctx.stroke() ctx.restore() def render_shade(self, shade_wkt): # Add the grey shade shade_shape = shapes.PolyShapeFile( self.canvas.get_actual_bounding_box(), os.path.join(self.tmpdir, 'shade.shp'), 'shade') shade_shape.add_shade_from_wkt(shade_wkt) self.canvas.add_shape_file(shade_shape, self.rc.stylesheet.shade_color, self.rc.stylesheet.shade_alpha) @staticmethod def convert_mm_to_pt(mm): return ((mm/10.0) / 2.54) * 72 # The next three methods are to be overloaded by the actual renderer. def create_map_canvas(self): """Creates the Mapnik map canvas to the appropriate graphical ratio so it can be scaled and fitted into the zone on the page dedicated to the map.""" raise NotImplementedError def render(self, surface, street_index): """Renders the map, the index and all other visual map features on the given Cairo surface. Args: surface (cairo.Surface): the Cairo surface to render into. street_index (index.StreetIndex): the street index, that would be rendered by a renderer that supports it. """ raise NotImplementedError @staticmethod def get_compatible_paper_sizes(bounding_box, zoom_level, resolution_km_in_mm): """Returns a list of the compatible paper sizes for the given bounding box. The list is sorted, smaller papers first, and a "custom" paper matching the dimensions of the bounding box is added at the end. Args: bounding_box (coords.BoundingBox): the map geographic bounding box. zoom_level (int): the Mapnik zoom level to use, generally 16. resolution_km_in_mm (int): size of a geographic kilometer in milimeters on the rendered map. Returns a list of tuples (paper name, width in mm, height in mm). Paper sizes are represented in portrait mode. """ raise NotImplementedError class PlainRenderer(Renderer): """ The PlainRenderer is the simplest renderer we have. It creates a full-page map, with the overlayed features like the grid, grid labels, scale and compass rose but no index. """ name = 'plain' description = 'A basic, full-page layout for the map.' def __init__(self, rc, tmpdir): Renderer.__init__(self, rc, tmpdir) self.grid_legend_margin_pt = \ min(Renderer.GRID_LEGEND_MARGIN_RATIO * self.paper_width_pt, Renderer.GRID_LEGEND_MARGIN_RATIO * self.paper_height_pt) self.map_area_width_pt = (self.paper_width_pt - 2 * (Renderer.PRINT_SAFE_MARGIN_PT + self.grid_legend_margin_pt)) self.map_area_height_pt = (self.paper_height_pt - 2 * (Renderer.PRINT_SAFE_MARGIN_PT + self.grid_legend_margin_pt)) def create_map_canvas(self): self._create_map_canvas(float(self.map_area_width_pt) / float(self.map_area_height_pt)) def render(self, surface, street_index): """...""" l.info('PlainRenderer rendering on %dx%dmm paper.' % (self.rc.paper_width_mm, self.rc.paper_height_mm)) rendered_map = self.canvas.get_rendered_map() ctx = cairo.Context(surface) ctx.translate(Renderer.PRINT_SAFE_MARGIN_PT, Renderer.PRINT_SAFE_MARGIN_PT) ctx.save() ctx.translate(self.grid_legend_margin_pt, self.grid_legend_margin_pt) ctx.scale(self.map_area_width_pt / rendered_map.width, self.map_area_height_pt / rendered_map.height) # Render the map canvas to the Cairo surface mapnik.render(rendered_map, ctx) # Draw a rectangle around the map self._draw_rectangle(ctx, 0, 0, rendered_map.width, rendered_map.height, self.rc.stylesheet.grid_line_width) ctx.restore() # Place the vertical and horizontal square labels self._draw_labels(ctx) # TODO: map scale # TODO: compass rose surface.flush() return surface def _draw_centered_text(self, ctx, text, x, y): ctx.save() xb, yb, tw, th, xa, ya = ctx.text_extents(text) ctx.move_to(x - tw/2.0 - xb, y - yb/2.0) ctx.show_text(text) ctx.stroke() ctx.restore() def _draw_labels(self, ctx): ctx.save() step_horiz = self.map_area_width_pt / self.grid.horiz_count last_horiz_portion = math.modf(self.grid.horiz_count)[0] step_vert = self.map_area_height_pt / self.grid.vert_count last_vert_portion = math.modf(self.grid.vert_count)[0] ctx.set_font_size(min(0.75 * self.grid_legend_margin_pt, 0.5 * step_horiz)) for i, label in enumerate(self.grid.horizontal_labels): x = self.grid_legend_margin_pt + i * step_horiz if i < len(self.grid.horizontal_labels) - 1: x += step_horiz/2.0 elif last_horiz_portion >= 0.25: x += step_horiz * last_horiz_portion/2.0 else: continue self._draw_centered_text(ctx, label, x, self.grid_legend_margin_pt/2.0) self._draw_centered_text(ctx, label, x, self.map_area_height_pt + 3*self.grid_legend_margin_pt/2.0) for i, label in enumerate(self.grid.vertical_labels): y = self.grid_legend_margin_pt + i * step_vert if i < len(self.grid.vertical_labels) - 1: y += step_vert/2.0 elif last_vert_portion >= 0.25: y += step_vert * last_vert_portion/2.0 else: continue self._draw_centered_text(ctx, label, self.grid_legend_margin_pt/2.0, y) self._draw_centered_text(ctx, label, self.map_area_width_pt + 3*self.grid_legend_margin_pt/2.0, y) ctx.restore() @staticmethod def get_compatible_paper_sizes(bounding_box, zoom_level, resolution_km_in_mm=Renderer.DEFAULT_KM_IN_MM): """Returns a list of paper sizes that can accomodate the provided bounding box at the given zoom level and print resolution.""" geo_width_m, geo_height_m = bounding_box.spheric_sizes() paper_width_mm = geo_width_m/1000.0 * resolution_km_in_mm paper_height_mm = geo_height_m/1000.0 * resolution_km_in_mm l.debug('Map represents %dx%dm, needs at least %.1fx%.1fcm ' 'on paper.' % (geo_width_m, geo_height_m, paper_width_mm/10, paper_height_mm/10)) # Test both portrait and landscape orientations when checking for paper # sizes. valid_sizes = filter(lambda (name,w,h): (paper_width_mm <= w and paper_height_mm <= h) or (paper_width_mm <= h and paper_height_mm <= w), Renderer.PAPER_SIZES) # Add a 'Custom' paper format to the list that perfectly matches the # bounding box. valid_sizes.append(('Best fit', paper_width_mm, paper_height_mm)) return valid_sizes # The renderers registry _RENDERERS = [PlainRenderer] def get_renderer_class_by_name(name): """Retrieves a renderer class, by name.""" for renderer in _RENDERERS: if renderer.name == name: return renderer raise LookupError, 'The requested renderer %s was not found!' % name def get_renderers(): """Returns the list of available renderers' names.""" return _RENDERERS if __name__ == '__main__': import coords import cairo logging.basicConfig(level=logging.DEBUG) bbox = coords.BoundingBox(48.8162, 2.3417, 48.8063, 2.3699) zoom = 16 renderer_cls = get_renderer_class_by_name('plain') papers = renderer_cls.get_compatible_paper_sizes(bbox, zoom) print 'Compatible paper sizes:' for p in papers: print ' * %s (%.1fx%.1fcm)' % (p[0], p[1]/10.0, p[2]/10.0) print 'Using first available:', papers[0] class StylesheetMock: def __init__(self): self.path = '/home/sam/src/python/maposmatic/mapnik-osm/osm.xml' self.grid_line_color = 'black' self.grid_line_alpha = 0.9 self.grid_line_width = 2 self.zoom_level = 16 class RenderingConfigurationMock: def __init__(self): self.stylesheet = StylesheetMock() self.bounding_box = bbox self.paper_width_mm = papers[0][1] self.paper_height_mm = papers[0][2] self.rtl = False config = RenderingConfigurationMock() plain = renderer_cls(config, '/tmp') surface = cairo.PDFSurface('/tmp/plain.pdf', Renderer.convert_mm_to_pt(config.paper_width_mm), Renderer.convert_mm_to_pt(config.paper_height_mm)) plain.create_map_canvas() plain.canvas.render() plain.render(surface, None) surface.finish()