Categories
OpenStreetMap Technical

Map Adornments with Cairo

For my OpenOrienteeringMap service, I have a added PDF creation facility, which produces a map, ready to print, with a title, north arrow, club logo and link back to the website.

The map itself is rendered in Mapnik, which uses Cairo, using Python and the pycairo bindings. To add the adornments, I’ve also made use of these bindings, and use them at the same time.

The adornments are shown above highlighted in yellow, and the purple control circles and numbers are also added directly using Cairo, rather than being rendered as geospatial objects in Mapnik. This blog post concentrates on the ones at the top of the sheet.

Here’s part of the Python script used to produce the PDF. S2P is a constant used to convert from metres (i.e. map units) to points (i.e. screen/paper units) and its value is 72/0.0254 (72 points per inch, 1/2.54 inches per cm).

I’ve generally use capitals for the names for the various positioning values, with a sort of naming convention – “W” is width, “WM” is west (i.e. left) margin. For example, ADORN_L_W is the width of the logo adornment, in metres. The “surface” sheet is made up of various “contexts” – in effect content boxes, which are positioned and scaled onto the surface, and filled with the content.

We need to import some modules:

import tempfile
import mapnik
import cairo
import urllib

Firstly, set up the PDF sheet, or “surface”, specifying its size:

file = tempfile.NamedTemporaryFile()
surface = cairo.PDFSurface(file.name, PAPER_W*S2P, PAPER_H*S2P)

It’s important to specify the sizes accurately so that printers will print without trying to reduce the PDF, so rendering the scale inaccurate. For example, A4 landscape needs to have a PAPER_W of exactly 0.2970 and PAPER_H of 0.2100.

Then set up the map element – note Mapnik requires integers for the widths and heights:

map = mapnik.Map(int(MAP_W*S2P), int(MAP_H*S2P))
mapnik.load_map(map, styleFile)
map.zoom_to_box(cbbox)

Create a context on the surface to draw the map onto, and shift it to allow for margins on the page.

ctx = cairo.Context(surface)
ctx.translate(MAP_WM*S2P, MAP_NM*S2P)
mapnik.render(map, ctx)

Then, just add each adornment in the right place. First the title – the text has been supplied in the URL so is decoded first:

text = urllib.unquote(title)

Then, write the title onto the surface using show_text. Obviously, the server needs to have the fonts installed – I was using Deja Vu Sans initially, but Arial is used for the “regular” Street-O maps that I’m mimicing, so I installed the Microsoft Truetype Core Fonts for Linux:

ctx = cairo.Context(surface)
ctx.select_font_face("Arial Black", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
ctx.set_font_size(24)
ctx.translate(MAP_WM*S2P, (MAP_NM-ADORN_T_SM)*S2P)
ctx.show_text(text)

The image above – and the ones below – are shown at 150% of their actual size on screen.

Now to add a scale caption:

ctx = cairo.Context(surface)
ctx.select_font_face("Arial", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
text = "Scale 1:" + str(scale)
ctx.set_font_size(14)
width = ctx.text_extents(text)[4]
ctx.translate((MAP_WM+MAP_W)*S2P-width-(ADORN_A_W+ADORN_L_W)*S2P, (MAP_NM-ADORN_S_SM)*S2P)
ctx.show_text(text)

..and below it, a scalebar and indicator:

ctx = cairo.Context(surface)
ctx.select_font_face("Arial", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
scaleBarMetres = 500
if scale < 10000:
    scaleBarMetres = 200
text = str(scaleBarMetres) + "m"
ctx.set_font_size(7)
width = ctx.text_extents(text)[4]
barCaptionX = (MAP_WM+MAP_W-(ADORN_A_W+ADORN_L_W))*S2P-width
ctx.translate(barCaptionX, (MAP_NM-ADORN_S_SM)*S2P)
ctx.show_text(text)

..the scalebar itself, making use of stroke:

ctx.set_line_width(0.5)
scaleBarW = scaleBarMetres/float(scale)
ctx.move_to((-scaleBarW-ADORN_S_PADDING)*S2P, 0)
ctx.rel_line_to(0, -ADORN_S_LARGETICK*S2P)
ctx.rel_line_to(0, ADORN_S_LARGETICK*S2P)
ctx.rel_line_to(scaleBarW*S2P/2, 0)
ctx.rel_line_to(0, -ADORN_S_SMALLTICK*S2P)
ctx.rel_line_to(0, ADORN_S_SMALLTICK*S2P)
ctx.rel_line_to(scaleBarW*S2P/2, 0)
ctx.rel_line_to(0, -ADORN_S_LARGETICK*S2P)
ctx.stroke()

The north-arrow is done in a similar way. close_path is used to create a proper triangle, with no “ends” that might result in ugly capping effects. fill makes it solid.

ctx = cairo.Context(surface)
ctx.translate((MAP_WM+MAP_W-ADORN_L_W)*S2P-width, CONTENT_NM*S2P)
ctx.set_line_width(1)
ctx.move_to(0, 0)
ctx.line_to(0.001*S2P, 0.002*S2P)
ctx.line_to(-0.001*S2P, 0.002*S2P)
ctx.close_path()
ctx.fill()

The “N” below the arrow is also drawn with lines, but using round line joins and line caps to produce a smooth letter.

ctx.move_to(0, 0.001*S2P)
ctx.line_to(0, 0.008*S2P)
ctx.stroke()
ctx.set_line_join(cairo.LINE_JOIN_ROUND)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.move_to(-0.001*S2P, 0.005*S2P)
ctx.rel_line_to(0, -0.002*S2P)
ctx.rel_line_to(0.002*S2P, 0.002*S2P)
ctx.rel_line_to(0, -0.002*S2P)
ctx.stroke()

Finally, a logo is added. This is a bit trickier – the logo is a PNG, but the surface is a PDF. The way I got around this is to create a temporary ImageSurface and then switch the surface that the context is on – the graphic also gets appropriately scaled:

logoSf = cairo.ImageSurface.create_from_png(home+"/logo.png")
ctx = cairo.Context(surface)
width = logoSf.get_width()*ADORN_L_SCALE
ctx.translate((MAP_WM+MAP_W)*S2P-width, CONTENT_NM*S2P)
ctx.scale(ADORN_L_SCALE, ADORN_L_SCALE)
ctx.set_source_surface(logoSf , 0, 0)
ctx.paint()

Finally, putting it all together:

surface.finish()
return file

Leave a Reply

Your email address will not be published. Required fields are marked *