1027 lines
41 KiB
Python
1027 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
"""Mapnik classes to assist in creating printable maps
|
|
|
|
basic usage is along the lines of
|
|
|
|
import mapnik
|
|
|
|
page = mapnik.printing.PDFPrinter()
|
|
m = mapnik.Map(100,100)
|
|
mapnik.load_map(m, "my_xml_map_description", True)
|
|
m.zoom_all()
|
|
page.render_map(m,"my_output_file.pdf")
|
|
|
|
see the documentation of mapnik.printing.PDFPrinter() for options
|
|
|
|
"""
|
|
from __future__ import absolute_import
|
|
|
|
from . import render, Map, Box2d, Layer, Feature, Projection, ProjTransform, Coord, Style, Rule, Geometry2d
|
|
import math
|
|
import os
|
|
import tempfile
|
|
|
|
try:
|
|
import cairo
|
|
HAS_PYCAIRO_MODULE = True
|
|
except ImportError:
|
|
HAS_PYCAIRO_MODULE = False
|
|
|
|
try:
|
|
import pangocairo
|
|
import pango
|
|
HAS_PANGOCAIRO_MODULE = True
|
|
except ImportError:
|
|
HAS_PANGOCAIRO_MODULE = False
|
|
|
|
try:
|
|
import pyPdf
|
|
HAS_PYPDF = True
|
|
except ImportError:
|
|
HAS_PYPDF = False
|
|
|
|
class centering:
|
|
"""Style of centering to use with the map, the default is constrained
|
|
|
|
none: map will be placed flush with the margin/box in the top left corner
|
|
constrained: map will be centered on the most constrained axis (for a portrait page
|
|
and a square map this will be horizontally)
|
|
unconstrained: map will be centered on the unconstrained axis
|
|
vertical:
|
|
horizontal:
|
|
both:
|
|
"""
|
|
none=0
|
|
constrained=1
|
|
unconstrained=2
|
|
vertical=3
|
|
horizontal=4
|
|
both=5
|
|
|
|
"""Some predefined page sizes custom sizes can also be passed
|
|
a tuple of the page width and height in meters"""
|
|
pagesizes = {
|
|
"a0": (0.841000,1.189000),
|
|
"a0l": (1.189000,0.841000),
|
|
"b0": (1.000000,1.414000),
|
|
"b0l": (1.414000,1.000000),
|
|
"c0": (0.917000,1.297000),
|
|
"c0l": (1.297000,0.917000),
|
|
"a1": (0.594000,0.841000),
|
|
"a1l": (0.841000,0.594000),
|
|
"b1": (0.707000,1.000000),
|
|
"b1l": (1.000000,0.707000),
|
|
"c1": (0.648000,0.917000),
|
|
"c1l": (0.917000,0.648000),
|
|
"a2": (0.420000,0.594000),
|
|
"a2l": (0.594000,0.420000),
|
|
"b2": (0.500000,0.707000),
|
|
"b2l": (0.707000,0.500000),
|
|
"c2": (0.458000,0.648000),
|
|
"c2l": (0.648000,0.458000),
|
|
"a3": (0.297000,0.420000),
|
|
"a3l": (0.420000,0.297000),
|
|
"b3": (0.353000,0.500000),
|
|
"b3l": (0.500000,0.353000),
|
|
"c3": (0.324000,0.458000),
|
|
"c3l": (0.458000,0.324000),
|
|
"a4": (0.210000,0.297000),
|
|
"a4l": (0.297000,0.210000),
|
|
"b4": (0.250000,0.353000),
|
|
"b4l": (0.353000,0.250000),
|
|
"c4": (0.229000,0.324000),
|
|
"c4l": (0.324000,0.229000),
|
|
"a5": (0.148000,0.210000),
|
|
"a5l": (0.210000,0.148000),
|
|
"b5": (0.176000,0.250000),
|
|
"b5l": (0.250000,0.176000),
|
|
"c5": (0.162000,0.229000),
|
|
"c5l": (0.229000,0.162000),
|
|
"a6": (0.105000,0.148000),
|
|
"a6l": (0.148000,0.105000),
|
|
"b6": (0.125000,0.176000),
|
|
"b6l": (0.176000,0.125000),
|
|
"c6": (0.114000,0.162000),
|
|
"c6l": (0.162000,0.114000),
|
|
"a7": (0.074000,0.105000),
|
|
"a7l": (0.105000,0.074000),
|
|
"b7": (0.088000,0.125000),
|
|
"b7l": (0.125000,0.088000),
|
|
"c7": (0.081000,0.114000),
|
|
"c7l": (0.114000,0.081000),
|
|
"a8": (0.052000,0.074000),
|
|
"a8l": (0.074000,0.052000),
|
|
"b8": (0.062000,0.088000),
|
|
"b8l": (0.088000,0.062000),
|
|
"c8": (0.057000,0.081000),
|
|
"c8l": (0.081000,0.057000),
|
|
"a9": (0.037000,0.052000),
|
|
"a9l": (0.052000,0.037000),
|
|
"b9": (0.044000,0.062000),
|
|
"b9l": (0.062000,0.044000),
|
|
"c9": (0.040000,0.057000),
|
|
"c9l": (0.057000,0.040000),
|
|
"a10": (0.026000,0.037000),
|
|
"a10l": (0.037000,0.026000),
|
|
"b10": (0.031000,0.044000),
|
|
"b10l": (0.044000,0.031000),
|
|
"c10": (0.028000,0.040000),
|
|
"c10l": (0.040000,0.028000),
|
|
"letter": (0.216,0.279),
|
|
"letterl": (0.279,0.216),
|
|
"legal": (0.216,0.356),
|
|
"legall": (0.356,0.216),
|
|
}
|
|
"""size of a pt in meters"""
|
|
pt_size=0.0254/72.0
|
|
|
|
def m2pt(x):
|
|
"""convert distance from meters to points"""
|
|
return x/pt_size
|
|
|
|
def pt2m(x):
|
|
"""convert distance from points to meters"""
|
|
return x*pt_size
|
|
|
|
def m2in(x):
|
|
"""convert distance from meters to inches"""
|
|
return x/0.0254
|
|
|
|
def m2px(x,resolution):
|
|
"""convert distance from meters to pixels at the given resolution in DPI/PPI"""
|
|
return m2in(x)*resolution
|
|
|
|
class resolutions:
|
|
"""some predefined resolutions in DPI"""
|
|
dpi72=72
|
|
dpi150=150
|
|
dpi300=300
|
|
dpi600=600
|
|
|
|
def any_scale(scale):
|
|
"""Scale helper function that allows any scale"""
|
|
return scale
|
|
|
|
def sequence_scale(scale,scale_sequence):
|
|
"""Default scale helper, this rounds scale to a 'sensible' value"""
|
|
factor = math.floor(math.log10(scale))
|
|
norm = scale/(10**factor)
|
|
|
|
for s in scale_sequence:
|
|
if norm <= s:
|
|
return s*10**factor
|
|
return scale_sequence[0]*10**(factor+1)
|
|
|
|
def default_scale(scale):
|
|
"""Default scale helper, this rounds scale to a 'sensible' value"""
|
|
return sequence_scale(scale, (1,1.25,1.5,1.75,2,2.5,3,4,5,6,7.5,8,9,10))
|
|
|
|
def deg_min_sec_scale(scale):
|
|
for x in (1.0/3600,
|
|
2.0/3600,
|
|
5.0/3600,
|
|
10.0/3600,
|
|
30.0/3600,
|
|
1.0/60,
|
|
2.0/60,
|
|
5.0/60,
|
|
10.0/60,
|
|
30.0/60,
|
|
1,
|
|
2,
|
|
5,
|
|
10,
|
|
30,
|
|
60
|
|
):
|
|
if scale < x:
|
|
return x
|
|
else:
|
|
return x
|
|
|
|
def format_deg_min_sec(value):
|
|
deg = math.floor(value)
|
|
min = math.floor((value-deg)/(1.0/60))
|
|
sec = int((value - deg*1.0/60)/1.0/3600)
|
|
return "%d°%d'%d\"" % (deg,min,sec)
|
|
|
|
def round_grid_generator(first,last,step):
|
|
val = (math.floor(first / step) + 1) * step
|
|
yield val
|
|
while val < last:
|
|
val += step
|
|
yield val
|
|
|
|
|
|
def convert_pdf_pages_to_layers(filename,output_name=None,layer_names=(),reverse_all_but_last=True):
|
|
"""
|
|
opens the given multipage PDF and converts each page to be a layer in a single page PDF
|
|
layer_names should be a sequence of the user visible names of the layers, if not given
|
|
or if shorter than num pages generic names will be given to the unnamed layers
|
|
|
|
if output_name is not provided a temporary file will be used for the conversion which
|
|
will then be copied back over the source file.
|
|
|
|
requires pyPdf >= 1.13 to be available"""
|
|
|
|
|
|
if not HAS_PYPDF:
|
|
raise Exception("pyPdf Not available")
|
|
|
|
infile = file(filename, 'rb')
|
|
if output_name:
|
|
outfile = file(output_name, 'wb')
|
|
else:
|
|
(outfd,outfilename) = tempfile.mkstemp(dir=os.path.dirname(filename))
|
|
outfile = os.fdopen(outfd,'wb')
|
|
|
|
i = pyPdf.PdfFileReader(infile)
|
|
o = pyPdf.PdfFileWriter()
|
|
|
|
template_page_size = i.pages[0].mediaBox
|
|
op = o.addBlankPage(width=template_page_size.getWidth(),height=template_page_size.getHeight())
|
|
|
|
contentkey = pyPdf.generic.NameObject('/Contents')
|
|
resourcekey = pyPdf.generic.NameObject('/Resources')
|
|
propertieskey = pyPdf.generic.NameObject('/Properties')
|
|
op[contentkey] = pyPdf.generic.ArrayObject()
|
|
op[resourcekey] = pyPdf.generic.DictionaryObject()
|
|
properties = pyPdf.generic.DictionaryObject()
|
|
ocgs = pyPdf.generic.ArrayObject()
|
|
|
|
for (i, p) in enumerate(i.pages):
|
|
# first start an OCG for the layer
|
|
ocgname = pyPdf.generic.NameObject('/oc%d' % i)
|
|
ocgstart = pyPdf.generic.DecodedStreamObject()
|
|
ocgstart._data = "/OC %s BDC\n" % ocgname
|
|
ocgend = pyPdf.generic.DecodedStreamObject()
|
|
ocgend._data = "EMC\n"
|
|
if isinstance(p['/Contents'],pyPdf.generic.ArrayObject):
|
|
p[pyPdf.generic.NameObject('/Contents')].insert(0,ocgstart)
|
|
p[pyPdf.generic.NameObject('/Contents')].append(ocgend)
|
|
else:
|
|
p[pyPdf.generic.NameObject('/Contents')] = pyPdf.generic.ArrayObject((ocgstart,p['/Contents'],ocgend))
|
|
|
|
op.mergePage(p)
|
|
|
|
ocg = pyPdf.generic.DictionaryObject()
|
|
ocg[pyPdf.generic.NameObject('/Type')] = pyPdf.generic.NameObject('/OCG')
|
|
if len(layer_names) > i:
|
|
ocg[pyPdf.generic.NameObject('/Name')] = pyPdf.generic.TextStringObject(layer_names[i])
|
|
else:
|
|
ocg[pyPdf.generic.NameObject('/Name')] = pyPdf.generic.TextStringObject('Layer %d' % (i+1))
|
|
indirect_ocg = o._addObject(ocg)
|
|
properties[ocgname] = indirect_ocg
|
|
ocgs.append(indirect_ocg)
|
|
|
|
op[resourcekey][propertieskey] = o._addObject(properties)
|
|
|
|
ocproperties = pyPdf.generic.DictionaryObject()
|
|
ocproperties[pyPdf.generic.NameObject('/OCGs')] = ocgs
|
|
defaultview = pyPdf.generic.DictionaryObject()
|
|
defaultview[pyPdf.generic.NameObject('/Name')] = pyPdf.generic.TextStringObject('Default')
|
|
defaultview[pyPdf.generic.NameObject('/BaseState ')] = pyPdf.generic.NameObject('/ON ')
|
|
defaultview[pyPdf.generic.NameObject('/ON')] = ocgs
|
|
if reverse_all_but_last:
|
|
defaultview[pyPdf.generic.NameObject('/Order')] = pyPdf.generic.ArrayObject(reversed(ocgs[:-1]))
|
|
defaultview[pyPdf.generic.NameObject('/Order')].append(ocgs[-1])
|
|
else:
|
|
defaultview[pyPdf.generic.NameObject('/Order')] = pyPdf.generic.ArrayObject(reversed(ocgs))
|
|
defaultview[pyPdf.generic.NameObject('/OFF')] = pyPdf.generic.ArrayObject()
|
|
|
|
ocproperties[pyPdf.generic.NameObject('/D')] = o._addObject(defaultview)
|
|
|
|
o._root.getObject()[pyPdf.generic.NameObject('/OCProperties')] = o._addObject(ocproperties)
|
|
|
|
o.write(outfile)
|
|
|
|
outfile.close()
|
|
infile.close()
|
|
|
|
if not output_name:
|
|
os.rename(outfilename, filename)
|
|
|
|
class PDFPrinter:
|
|
"""Main class for creating PDF print outs, basically contruct an instance
|
|
with appropriate options and then call render_map with your mapnik map
|
|
"""
|
|
def __init__(self,
|
|
pagesize=pagesizes["a4"],
|
|
margin=0.005,
|
|
box=None,
|
|
percent_box=None,
|
|
scale=default_scale,
|
|
resolution=resolutions.dpi72,
|
|
preserve_aspect=True,
|
|
centering=centering.constrained,
|
|
is_latlon=False,
|
|
use_ocg_layers=False):
|
|
"""Creates a cairo surface and context to render a PDF with.
|
|
|
|
pagesize: tuple of page size in meters, see predefined sizes in pagessizes dict (default a4)
|
|
margin: page margin in meters (default 0.01)
|
|
box: box within the page to render the map into (will not render over margin). This should be
|
|
a Mapnik Box2d object. Default is the full page within the margin
|
|
percent_box: as per box, but specified as a percent (0->1) of the full page size. If both box
|
|
and percent_box are specified percent_box will be used.
|
|
scale: scale helper to use when rounding the map scale. This should be a function that
|
|
takes a single float and returns a float which is at least as large as the value
|
|
passed in. This is a 1:x scale.
|
|
resolution: the resolution to render non vector elements at (in DPI), defaults to 72 DPI
|
|
preserve_aspect: whether to preserve map aspect ratio. This defaults to True and it
|
|
is recommended you do not change it unless you know what you are doing
|
|
scales and so on will not work if this is False.
|
|
centering: Centering rules for maps where the scale rounding has reduced the map size.
|
|
This should be a value from the centering class. The default is to center on the
|
|
maps constrained axis, typically this will be horizontal for portrait pages and
|
|
vertical for landscape pages.
|
|
is_latlon: Is the map in lat lon degrees. If true magic anti meridian logic is enabled
|
|
use_ocg_layers: Create OCG layers in the PDF, requires pyPdf >= 1.13
|
|
"""
|
|
self._pagesize = pagesize
|
|
self._margin = margin
|
|
self._box = box
|
|
self._scale = scale
|
|
self._resolution = resolution
|
|
self._preserve_aspect = preserve_aspect
|
|
self._centering = centering
|
|
self._is_latlon = is_latlon
|
|
self._use_ocg_layers = use_ocg_layers
|
|
|
|
self._s = None
|
|
self._layer_names = []
|
|
self._filename = None
|
|
|
|
self.map_box = None
|
|
self.scale = None
|
|
|
|
# don't both to round the scale if they are not preserving the aspect ratio
|
|
if not preserve_aspect:
|
|
self._scale = any_scale
|
|
|
|
if percent_box:
|
|
self._box = Box2d(percent_box[0]*pagesize[0],percent_box[1]*pagesize[1],
|
|
percent_box[2]*pagesize[0],percent_box[3]*pagesize[1])
|
|
|
|
if not HAS_PYCAIRO_MODULE:
|
|
raise Exception("PDF rendering only available when pycairo is available")
|
|
|
|
self.font_name = "DejaVu Sans"
|
|
|
|
def finish(self):
|
|
if self._s:
|
|
self._s.finish()
|
|
self._s = None
|
|
|
|
if self._use_ocg_layers:
|
|
convert_pdf_pages_to_layers(self._filename,layer_names=self._layer_names + ["Legend and Information"],reverse_all_but_last=True)
|
|
|
|
def add_geospatial_pdf_header(self,m,filename,epsg=None,wkt=None):
|
|
""" Postprocessing step to add geospatial PDF information to PDF file as per
|
|
PDF standard 1.7 extension level 3 (also in draft PDF v2 standard at time of writing)
|
|
|
|
one of either the epsg code or wkt text for the projection must be provided
|
|
|
|
Should be called *after* the page has had .finish() called"""
|
|
if HAS_PYPDF and (epsg or wkt):
|
|
infile=file(filename,'rb')
|
|
(outfd,outfilename) = tempfile.mkstemp(dir=os.path.dirname(filename))
|
|
outfile = os.fdopen(outfd,'wb')
|
|
|
|
i=pyPdf.PdfFileReader(infile)
|
|
o=pyPdf.PdfFileWriter()
|
|
|
|
# preserve OCProperties at document root if we have one
|
|
if i.trailer['/Root'].has_key(pyPdf.generic.NameObject('/OCProperties')):
|
|
o._root.getObject()[pyPdf.generic.NameObject('/OCProperties')] = i.trailer['/Root'].getObject()[pyPdf.generic.NameObject('/OCProperties')]
|
|
|
|
for p in i.pages:
|
|
gcs = pyPdf.generic.DictionaryObject()
|
|
gcs[pyPdf.generic.NameObject('/Type')]=pyPdf.generic.NameObject('/PROJCS')
|
|
if epsg:
|
|
gcs[pyPdf.generic.NameObject('/EPSG')]=pyPdf.generic.NumberObject(int(epsg))
|
|
if wkt:
|
|
gcs[pyPdf.generic.NameObject('/WKT')]=pyPdf.generic.TextStringObject(wkt)
|
|
|
|
measure = pyPdf.generic.DictionaryObject()
|
|
measure[pyPdf.generic.NameObject('/Type')]=pyPdf.generic.NameObject('/Measure')
|
|
measure[pyPdf.generic.NameObject('/Subtype')]=pyPdf.generic.NameObject('/GEO')
|
|
measure[pyPdf.generic.NameObject('/GCS')]=gcs
|
|
bounds=pyPdf.generic.ArrayObject()
|
|
for x in (0.0,0.0,0.0,1.0,1.0,1.0,1.0,0.0):
|
|
bounds.append(pyPdf.generic.FloatObject(str(x)))
|
|
measure[pyPdf.generic.NameObject('/Bounds')]=bounds
|
|
measure[pyPdf.generic.NameObject('/LPTS')]=bounds
|
|
gpts=pyPdf.generic.ArrayObject()
|
|
|
|
proj=Projection(m.srs)
|
|
env=m.envelope()
|
|
for x in ((env.minx, env.miny), (env.minx, env.maxy), (env.maxx, env.maxy), (env.maxx, env.miny)):
|
|
latlon_corner=proj.inverse(Coord(*x))
|
|
# these are in lat,lon order according to the standard
|
|
gpts.append(pyPdf.generic.FloatObject(str(latlon_corner.y)))
|
|
gpts.append(pyPdf.generic.FloatObject(str(latlon_corner.x)))
|
|
measure[pyPdf.generic.NameObject('/GPTS')]=gpts
|
|
|
|
vp=pyPdf.generic.DictionaryObject()
|
|
vp[pyPdf.generic.NameObject('/Type')]=pyPdf.generic.NameObject('/Viewport')
|
|
bbox=pyPdf.generic.ArrayObject()
|
|
|
|
for x in self.map_box:
|
|
bbox.append(pyPdf.generic.FloatObject(str(x)))
|
|
vp[pyPdf.generic.NameObject('/BBox')]=bbox
|
|
vp[pyPdf.generic.NameObject('/Measure')]=measure
|
|
|
|
vpa = pyPdf.generic.ArrayObject()
|
|
vpa.append(vp)
|
|
p[pyPdf.generic.NameObject('/VP')]=vpa
|
|
o.addPage(p)
|
|
|
|
o.write(outfile)
|
|
infile=None
|
|
outfile.close()
|
|
os.rename(outfilename,filename)
|
|
|
|
|
|
def get_context(self):
|
|
"""allow access so that extra 'bits' can be rendered to the page directly"""
|
|
return cairo.Context(self._s)
|
|
|
|
def get_width(self):
|
|
return self._pagesize[0]
|
|
|
|
def get_height(self):
|
|
return self._pagesize[1]
|
|
|
|
def get_margin(self):
|
|
return self._margin
|
|
|
|
def write_text(self,ctx,text,box_width=None,size=10, fill_color=(0.0, 0.0, 0.0), alignment=None):
|
|
if HAS_PANGOCAIRO_MODULE:
|
|
(attr,t,accel) = pango.parse_markup(text)
|
|
pctx = pangocairo.CairoContext(ctx)
|
|
l = pctx.create_layout()
|
|
l.set_attributes(attr)
|
|
fd = pango.FontDescription("%s %d" % (self.font_name,size))
|
|
l.set_font_description(fd)
|
|
if box_width:
|
|
l.set_width(int(box_width*pango.SCALE))
|
|
if alignment:
|
|
l.set_alignment(alignment)
|
|
pctx.update_layout(l)
|
|
l.set_text(t)
|
|
pctx.set_source_rgb(*fill_color)
|
|
pctx.show_layout(l)
|
|
return l.get_pixel_extents()[0]
|
|
|
|
else:
|
|
ctx.rel_move_to(0,size)
|
|
ctx.select_font_face(self.font_name, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
ctx.set_font_size(size)
|
|
ctx.show_text(text)
|
|
ctx.rel_move_to(0,size)
|
|
return (0,0,len(text)*size,size)
|
|
|
|
def _get_context(self):
|
|
if HAS_PANGOCAIRO_MODULE:
|
|
return
|
|
elif HAS_PYCAIRO_MODULE:
|
|
return cairo.Context(self._s)
|
|
return None
|
|
|
|
def _get_render_area(self):
|
|
"""return a bounding box with the area of the page we are allowed to render out map to
|
|
in page coordinates (i.e. meters)
|
|
"""
|
|
# take off our page margins
|
|
render_area = Box2d(self._margin,self._margin,self._pagesize[0]-self._margin,self._pagesize[1]-self._margin)
|
|
|
|
#then if user specified a box to render get intersection with that
|
|
if self._box:
|
|
return render_area.intersect(self._box)
|
|
|
|
return render_area
|
|
|
|
def _get_render_area_size(self):
|
|
"""Get the width and height (in meters) of the area we can render the map to, returned as a tuple"""
|
|
render_area = self._get_render_area()
|
|
return (render_area.width(),render_area.height())
|
|
|
|
def _is_h_contrained(self,m):
|
|
"""Test if the map size is constrained on the horizontal or vertical axes"""
|
|
available_area = self._get_render_area_size()
|
|
map_aspect = m.envelope().width()/m.envelope().height()
|
|
page_aspect = available_area[0]/available_area[1]
|
|
|
|
return map_aspect > page_aspect
|
|
|
|
def _get_meta_info_corner(self,render_size,m):
|
|
"""Get the corner (in page coordinates) of a possibly
|
|
sensible place to render metadata such as a legend or scale"""
|
|
(x,y) = self._get_render_corner(render_size,m)
|
|
if self._is_h_contrained(m):
|
|
y += render_size[1]+0.005
|
|
x = self._margin
|
|
else:
|
|
x += render_size[0]+0.005
|
|
y = self._margin
|
|
|
|
return (x,y)
|
|
|
|
def _get_render_corner(self,render_size,m):
|
|
"""Get the corner of the box we should render our map into"""
|
|
available_area = self._get_render_area()
|
|
|
|
x=available_area[0]
|
|
y=available_area[1]
|
|
|
|
h_is_contrained = self._is_h_contrained(m)
|
|
|
|
if (self._centering == centering.both or
|
|
self._centering == centering.horizontal or
|
|
(self._centering == centering.constrained and h_is_contrained) or
|
|
(self._centering == centering.unconstrained and not h_is_contrained)):
|
|
x+=(available_area.width()-render_size[0])/2
|
|
|
|
if (self._centering == centering.both or
|
|
self._centering == centering.vertical or
|
|
(self._centering == centering.constrained and not h_is_contrained) or
|
|
(self._centering == centering.unconstrained and h_is_contrained)):
|
|
y+=(available_area.height()-render_size[1])/2
|
|
return (x,y)
|
|
|
|
def _get_map_pixel_size(self, width_page_m, height_page_m):
|
|
"""for a given map size in paper coordinates return a tuple of the map 'pixel' size we
|
|
should create at the defined resolution"""
|
|
return (int(m2px(width_page_m,self._resolution)), int(m2px(height_page_m,self._resolution)))
|
|
|
|
def render_map(self,m, filename):
|
|
"""Render the given map to filename"""
|
|
|
|
# store this for later so we can post process the PDF
|
|
self._filename = filename
|
|
|
|
# work out the best scale to render out map at given the available space
|
|
(eff_width,eff_height) = self._get_render_area_size()
|
|
map_aspect = m.envelope().width()/m.envelope().height()
|
|
page_aspect = eff_width/eff_height
|
|
|
|
scalex=m.envelope().width()/eff_width
|
|
scaley=m.envelope().height()/eff_height
|
|
|
|
scale=max(scalex,scaley)
|
|
|
|
rounded_mapscale=self._scale(scale)
|
|
scalefactor = scale/rounded_mapscale
|
|
mapw=eff_width*scalefactor
|
|
maph=eff_height*scalefactor
|
|
if self._preserve_aspect:
|
|
if map_aspect > page_aspect:
|
|
maph=mapw*(1/map_aspect)
|
|
else:
|
|
mapw=maph*map_aspect
|
|
|
|
# set the map size so that raster elements render at the correct resolution
|
|
m.resize(*self._get_map_pixel_size(mapw,maph))
|
|
# calculate the translation for the map starting point
|
|
(tx,ty) = self._get_render_corner((mapw,maph),m)
|
|
|
|
# create our cairo surface and context and then render the map into it
|
|
self._s = cairo.PDFSurface(filename, m2pt(self._pagesize[0]),m2pt(self._pagesize[1]))
|
|
ctx=cairo.Context(self._s)
|
|
|
|
for l in m.layers:
|
|
# extract the layer names for naming layers if we use OCG
|
|
self._layer_names.append(l.name)
|
|
|
|
layer_map = Map(m.width,m.height,m.srs)
|
|
layer_map.layers.append(l)
|
|
for s in l.styles:
|
|
layer_map.append_style(s,m.find_style(s))
|
|
layer_map.zoom_to_box(m.envelope())
|
|
|
|
def render_map():
|
|
ctx.save()
|
|
ctx.translate(m2pt(tx),m2pt(ty))
|
|
#cairo defaults to 72dpi
|
|
ctx.scale(72.0/self._resolution,72.0/self._resolution)
|
|
render(layer_map, ctx)
|
|
ctx.restore()
|
|
|
|
# antimeridian
|
|
render_map()
|
|
if self._is_latlon and (m.envelope().minx < -180 or m.envelope().maxx > 180):
|
|
old_env = m.envelope()
|
|
if m.envelope().minx < -180:
|
|
delta = 360
|
|
else:
|
|
delta = -360
|
|
m.zoom_to_box(Box2d(old_env.minx+delta,old_env.miny,old_env.maxx+delta,old_env.maxy))
|
|
render_map()
|
|
# restore the original env
|
|
m.zoom_to_box(old_env)
|
|
|
|
if self._use_ocg_layers:
|
|
self._s.show_page()
|
|
|
|
self.scale = rounded_mapscale
|
|
self.map_box = Box2d(tx,ty,tx+mapw,ty+maph)
|
|
|
|
def render_on_map_lat_lon_grid(self,m,dec_degrees=True):
|
|
# don't render lat_lon grid if we are already in latlon
|
|
if self._is_latlon:
|
|
return
|
|
p2=Projection(m.srs)
|
|
|
|
latlon_bounds = p2.inverse(m.envelope())
|
|
if p2.inverse(m.envelope().center()).x > latlon_bounds.maxx:
|
|
latlon_bounds = Box2d(latlon_bounds.maxx,latlon_bounds.miny,latlon_bounds.minx+360,latlon_bounds.maxy)
|
|
|
|
if p2.inverse(m.envelope().center()).y > latlon_bounds.maxy:
|
|
latlon_bounds = Box2d(latlon_bounds.miny,latlon_bounds.maxy,latlon_bounds.maxx,latlon_bounds.miny+360)
|
|
|
|
latlon_mapwidth = latlon_bounds.width()
|
|
# render an extra 20% so we generally won't miss the ends of lines
|
|
latlon_buffer = 0.2*latlon_mapwidth
|
|
if dec_degrees:
|
|
latlon_divsize = default_scale(latlon_mapwidth/7.0)
|
|
else:
|
|
latlon_divsize = deg_min_sec_scale(latlon_mapwidth/7.0)
|
|
latlon_interpsize = latlon_mapwidth/m.width
|
|
|
|
self._render_lat_lon_axis(m,p2,latlon_bounds.minx,latlon_bounds.maxx,latlon_bounds.miny,latlon_bounds.maxy,latlon_buffer,latlon_interpsize,latlon_divsize,dec_degrees,True)
|
|
self._render_lat_lon_axis(m,p2,latlon_bounds.miny,latlon_bounds.maxy,latlon_bounds.minx,latlon_bounds.maxx,latlon_buffer,latlon_interpsize,latlon_divsize,dec_degrees,False)
|
|
|
|
def _render_lat_lon_axis(self,m,p2,x1,x2,y1,y2,latlon_buffer,latlon_interpsize,latlon_divsize,dec_degrees,is_x_axis):
|
|
ctx=cairo.Context(self._s)
|
|
ctx.set_source_rgb(1,0,0)
|
|
ctx.set_line_width(1)
|
|
latlon_labelsize = 6
|
|
|
|
ctx.translate(m2pt(self.map_box.minx),m2pt(self.map_box.miny))
|
|
ctx.rectangle(0,0,m2pt(self.map_box.width()),m2pt(self.map_box.height()))
|
|
ctx.clip()
|
|
|
|
ctx.select_font_face("DejaVu", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
ctx.set_font_size(latlon_labelsize)
|
|
|
|
box_top = self.map_box.height()
|
|
if not is_x_axis:
|
|
ctx.translate(m2pt(self.map_box.width()/2),m2pt(self.map_box.height()/2))
|
|
ctx.rotate(-math.pi/2)
|
|
ctx.translate(-m2pt(self.map_box.height()/2),-m2pt(self.map_box.width()/2))
|
|
box_top = self.map_box.width()
|
|
|
|
for xvalue in round_grid_generator(x1 - latlon_buffer,x2 + latlon_buffer,latlon_divsize):
|
|
yvalue = y1 - latlon_buffer
|
|
start_cross = None
|
|
end_cross = None
|
|
while yvalue < y2+latlon_buffer:
|
|
if is_x_axis:
|
|
start = m.view_transform().forward(p2.forward(Coord(xvalue,yvalue)))
|
|
else:
|
|
temp = m.view_transform().forward(p2.forward(Coord(yvalue,xvalue)))
|
|
start = Coord(m2pt(self.map_box.height())-temp.y,temp.x)
|
|
yvalue += latlon_interpsize
|
|
if is_x_axis:
|
|
end = m.view_transform().forward(p2.forward(Coord(xvalue,yvalue)))
|
|
else:
|
|
temp = m.view_transform().forward(p2.forward(Coord(yvalue,xvalue)))
|
|
end = Coord(m2pt(self.map_box.height())-temp.y,temp.x)
|
|
|
|
ctx.move_to(start.x,start.y)
|
|
ctx.line_to(end.x,end.y)
|
|
ctx.stroke()
|
|
|
|
if cmp(start.y, 0) != cmp(end.y,0):
|
|
start_cross = end.x
|
|
if cmp(start.y,m2pt(self.map_box.height())) != cmp(end.y, m2pt(self.map_box.height())):
|
|
end_cross = end.x
|
|
|
|
if dec_degrees:
|
|
line_text = "%g" % (xvalue)
|
|
else:
|
|
line_text = format_deg_min_sec(xvalue)
|
|
if start_cross:
|
|
ctx.move_to(start_cross+2,latlon_labelsize)
|
|
ctx.show_text(line_text)
|
|
if end_cross:
|
|
ctx.move_to(end_cross+2,m2pt(box_top)-2)
|
|
ctx.show_text(line_text)
|
|
|
|
def render_on_map_scale(self,m):
|
|
(div_size,page_div_size) = self._get_sensible_scalebar_size(m)
|
|
|
|
first_value_x = (math.floor(m.envelope().minx / div_size) + 1) * div_size
|
|
first_value_x_percent = (first_value_x-m.envelope().minx)/m.envelope().width()
|
|
self._render_scale_axis(first_value_x,first_value_x_percent,self.map_box.minx,self.map_box.maxx,page_div_size,div_size,self.map_box.miny,self.map_box.maxy,True)
|
|
|
|
first_value_y = (math.floor(m.envelope().miny / div_size) + 1) * div_size
|
|
first_value_y_percent = (first_value_y-m.envelope().miny)/m.envelope().height()
|
|
self._render_scale_axis(first_value_y,first_value_y_percent,self.map_box.miny,self.map_box.maxy,page_div_size,div_size,self.map_box.minx,self.map_box.maxx,False)
|
|
|
|
if self._use_ocg_layers:
|
|
self._s.show_page()
|
|
self._layer_names.append("Coordinate Grid Overlay")
|
|
|
|
def _get_sensible_scalebar_size(self,m,width=-1):
|
|
# aim for about 8 divisions across the map
|
|
# also make sure we can fit the bar with in page area width if specified
|
|
div_size = sequence_scale(m.envelope().width()/8, [1,2,5])
|
|
page_div_size = self.map_box.width()*div_size/m.envelope().width()
|
|
while width > 0 and page_div_size > width:
|
|
div_size /=2
|
|
page_div_size /= 2
|
|
return (div_size,page_div_size)
|
|
|
|
def _render_box(self,ctx,x,y,w,h,text=None,stroke_color=(0,0,0),fill_color=(0,0,0)):
|
|
ctx.set_line_width(1)
|
|
ctx.set_source_rgb(*fill_color)
|
|
ctx.rectangle(x,y,w,h)
|
|
ctx.fill()
|
|
|
|
ctx.set_source_rgb(*stroke_color)
|
|
ctx.rectangle(x,y,w,h)
|
|
ctx.stroke()
|
|
|
|
if text:
|
|
ctx.move_to(x+1,y)
|
|
self.write_text(ctx,text,fill_color=[1-z for z in fill_color],size=h-2)
|
|
|
|
def _render_scale_axis(self,first,first_percent,start,end,page_div_size,div_size,boundary_start,boundary_end,is_x_axis):
|
|
prev = start
|
|
text = None
|
|
fill=(0,0,0)
|
|
border_size=8
|
|
value = first_percent * (end-start) + start
|
|
label_value = first-div_size
|
|
if self._is_latlon and label_value < -180:
|
|
label_value += 360
|
|
|
|
ctx=cairo.Context(self._s)
|
|
|
|
if not is_x_axis:
|
|
ctx.translate(m2pt(self.map_box.center().x),m2pt(self.map_box.center().y))
|
|
ctx.rotate(-math.pi/2)
|
|
ctx.translate(-m2pt(self.map_box.center().y),-m2pt(self.map_box.center().x))
|
|
|
|
while value < end:
|
|
ctx.move_to(m2pt(value),m2pt(boundary_start))
|
|
ctx.line_to(m2pt(value),m2pt(boundary_end))
|
|
ctx.set_source_rgb(0.5,0.5,0.5)
|
|
ctx.set_line_width(1)
|
|
ctx.stroke()
|
|
|
|
for bar in (m2pt(boundary_start)-border_size,m2pt(boundary_end)):
|
|
self._render_box(ctx,m2pt(prev),bar,m2pt(value-prev),border_size,text,fill_color=fill)
|
|
|
|
prev = value
|
|
value+=page_div_size
|
|
fill = [1-z for z in fill]
|
|
label_value += div_size
|
|
if self._is_latlon and label_value > 180:
|
|
label_value -= 360
|
|
text = "%d" % label_value
|
|
else:
|
|
for bar in (m2pt(boundary_start)-border_size,m2pt(boundary_end)):
|
|
self._render_box(ctx,m2pt(prev),bar,m2pt(end-prev),border_size,fill_color=fill)
|
|
|
|
|
|
def render_scale(self,m,ctx=None,width=0.05):
|
|
""" m: map to render scale for
|
|
ctx: A cairo context to render the scale to. If this is None (the default) then
|
|
automatically create a context and choose the best location for the scale bar.
|
|
width: Width of area available to render scale bar in (in m)
|
|
|
|
will return the size of the rendered scale block in pts
|
|
"""
|
|
|
|
(w,h) = (0,0)
|
|
|
|
# don't render scale if we are lat lon
|
|
# dont report scale if we have warped the aspect ratio
|
|
if self._preserve_aspect and not self._is_latlon:
|
|
bar_size=8.0
|
|
box_count=3
|
|
if ctx is None:
|
|
ctx=cairo.Context(self._s)
|
|
(tx,ty) = self._get_meta_info_corner((self.map_box.width(),self.map_box.height()),m)
|
|
ctx.translate(tx,ty)
|
|
|
|
(div_size,page_div_size) = self._get_sensible_scalebar_size(m, width/box_count)
|
|
|
|
|
|
div_unit = "m"
|
|
if div_size > 1000:
|
|
div_size /= 1000
|
|
div_unit = "km"
|
|
|
|
text = "0%s" % div_unit
|
|
ctx.save()
|
|
if width > 0:
|
|
ctx.translate(m2pt(width-box_count*page_div_size)/2,0)
|
|
for ii in range(box_count):
|
|
fill=(ii%2,)*3
|
|
self._render_box(ctx, m2pt(ii*page_div_size), h, m2pt(page_div_size), bar_size, text, fill_color=fill)
|
|
fill = [1-z for z in fill]
|
|
text = "%g%s" % ((ii+1)*div_size,div_unit)
|
|
#else:
|
|
# self._render_box(ctx, m2pt(box_count*page_div_size), h, m2pt(page_div_size), bar_size, text, fill_color=(1,1,1), stroke_color=(1,1,1))
|
|
w = (box_count)*page_div_size
|
|
h += bar_size
|
|
ctx.restore()
|
|
|
|
if width > 0:
|
|
box_width=m2pt(width)
|
|
else:
|
|
box_width = None
|
|
|
|
font_size=6
|
|
ctx.move_to(0,h)
|
|
if HAS_PANGOCAIRO_MODULE:
|
|
alignment = pango.ALIGN_CENTER
|
|
else:
|
|
alignment = None
|
|
|
|
text_ext=self.write_text(ctx,"Scale 1:%d" % self.scale,box_width=box_width,size=font_size, alignment=alignment)
|
|
h+=text_ext[3]+2
|
|
|
|
return (w,h)
|
|
|
|
def render_legend(self,m, page_break=False, ctx=None, collumns=1,width=None, height=None, item_per_rule=False, attribution={}, legend_item_box_size=(0.015,0.0075)):
|
|
""" m: map to render legend for
|
|
ctx: A cairo context to render the legend to. If this is None (the default) then
|
|
automatically create a context and choose the best location for the legend.
|
|
width: Width of area available to render legend in (in m)
|
|
page_break: move to next page if legen over flows this one
|
|
collumns: number of collumns available in legend box
|
|
attribution: additional text that will be rendered in gray under the layer name. keyed by layer name
|
|
legend_item_box_size: two tuple with width and height of legend item box size in meters
|
|
|
|
will return the size of the rendered block in pts
|
|
"""
|
|
|
|
(w,h) = (0,0)
|
|
if self._s:
|
|
if ctx is None:
|
|
ctx=cairo.Context(self._s)
|
|
(tx,ty) = self._get_meta_info_corner((self.map_box.width(),self.map_box.height()),m)
|
|
ctx.translate(m2pt(tx),m2pt(ty))
|
|
width = self._pagesize[0]-2*tx
|
|
height = self._pagesize[1]-self._margin-ty
|
|
|
|
x=0
|
|
y=0
|
|
if width:
|
|
cwidth = width/collumns
|
|
w=m2pt(width)
|
|
else:
|
|
cwidth = None
|
|
current_collumn = 0
|
|
|
|
processed_layers = []
|
|
for l in reversed(m.layers):
|
|
have_layer_header = False
|
|
added_styles={}
|
|
layer_title = l.name
|
|
if layer_title in processed_layers:
|
|
continue
|
|
processed_layers.append(layer_title)
|
|
|
|
# check through the features to find which combinations of styles are active
|
|
# for each unique combination add a legend entry
|
|
for f in l.datasource.all_features():
|
|
if f.num_geometries() > 0:
|
|
active_rules = []
|
|
rule_text = ""
|
|
for s in l.styles:
|
|
st = m.find_style(s)
|
|
for r in st.rules:
|
|
# we need to do the scale test here as well so we don't
|
|
# add unused scale rules to the legend description
|
|
if ((not r.filter) or r.filter.evaluate(f) == '1') and \
|
|
r.min_scale <= m.scale_denominator() and m.scale_denominator() < r.max_scale:
|
|
active_rules.append((s,r.name))
|
|
if r.filter and str(r.filter) != "true":
|
|
if len(rule_text) > 0:
|
|
rule_text += " AND "
|
|
if r.name:
|
|
rule_text += r.name
|
|
else:
|
|
rule_text += str(r.filter)
|
|
active_rules = tuple(active_rules)
|
|
if added_styles.has_key(active_rules):
|
|
continue
|
|
|
|
added_styles[active_rules] = (f,rule_text)
|
|
if not item_per_rule:
|
|
break
|
|
else:
|
|
added_styles[l] = (None,None)
|
|
|
|
legend_items = added_styles.keys()
|
|
legend_items.sort()
|
|
for li in legend_items:
|
|
if True:
|
|
(f,rule_text) = added_styles[li]
|
|
|
|
|
|
legend_map_size = (int(m2pt(legend_item_box_size[0])),int(m2pt(legend_item_box_size[1])))
|
|
lemap=Map(legend_map_size[0],legend_map_size[1],srs=m.srs)
|
|
if m.background:
|
|
lemap.background = m.background
|
|
# the buffer is needed to ensure that text labels that overflow the edge of the
|
|
# map still render for the legend
|
|
lemap.buffer_size=1000
|
|
for s in l.styles:
|
|
sty=m.find_style(s)
|
|
lestyle = Style()
|
|
for r in sty.rules:
|
|
for sym in r.symbols:
|
|
try:
|
|
sym.avoid_edges=False
|
|
except:
|
|
print "**** Cant set avoid edges for rule", r.name
|
|
if r.min_scale <= m.scale_denominator() and m.scale_denominator() < r.max_scale:
|
|
lerule = r
|
|
lerule.min_scale = 0
|
|
lerule.max_scale = float("inf")
|
|
lestyle.rules.append(lerule)
|
|
lemap.append_style(s,lestyle)
|
|
|
|
ds = MemoryDatasource()
|
|
if f is None:
|
|
ds=l.datasource
|
|
layer_srs = l.srs
|
|
elif f.envelope().width() == 0:
|
|
ds.add_feature(Feature(f.id(),Geometry2d.from_wkt("POINT(0 0)"),**f.attributes))
|
|
lemap.zoom_to_box(Box2d(-1,-1,1,1))
|
|
layer_srs = m.srs
|
|
else:
|
|
ds.add_feature(f)
|
|
layer_srs = l.srs
|
|
|
|
lelayer = Layer("LegendLayer",layer_srs)
|
|
lelayer.datasource = ds
|
|
for s in l.styles:
|
|
lelayer.styles.append(s)
|
|
lemap.layers.append(lelayer)
|
|
|
|
if f is None or f.envelope().width() != 0:
|
|
lemap.zoom_all()
|
|
lemap.zoom(1.1)
|
|
|
|
item_size = legend_map_size[1]
|
|
if not have_layer_header:
|
|
item_size += 8
|
|
|
|
if y+item_size > m2pt(height):
|
|
current_collumn += 1
|
|
y=0
|
|
if current_collumn >= collumns:
|
|
if page_break:
|
|
self._s.show_page()
|
|
x=0
|
|
current_collumn = 0
|
|
else:
|
|
break
|
|
|
|
if not have_layer_header and item_per_rule:
|
|
ctx.move_to(x+m2pt(current_collumn*cwidth),y)
|
|
e=self.write_text(ctx, l.name, m2pt(cwidth), 8)
|
|
y+=e[3]+2
|
|
have_layer_header = True
|
|
ctx.save()
|
|
ctx.translate(x+m2pt(current_collumn*cwidth),y)
|
|
#extra save around map render as it sets up a clip box and doesn't clear it
|
|
ctx.save()
|
|
render(lemap, ctx)
|
|
ctx.restore()
|
|
|
|
ctx.rectangle(0,0,*legend_map_size)
|
|
ctx.set_source_rgb(0.5,0.5,0.5)
|
|
ctx.set_line_width(1)
|
|
ctx.stroke()
|
|
ctx.restore()
|
|
|
|
ctx.move_to(x+legend_map_size[0]+m2pt(current_collumn*cwidth)+2,y)
|
|
legend_entry_size = legend_map_size[1]
|
|
legend_text_size = 0
|
|
if not item_per_rule:
|
|
rule_text = layer_title
|
|
if rule_text:
|
|
e=self.write_text(ctx, rule_text, m2pt(cwidth-legend_item_box_size[0]-0.005), 6)
|
|
legend_text_size += e[3]
|
|
ctx.rel_move_to(0,e[3])
|
|
if attribution.has_key(layer_title):
|
|
e=self.write_text(ctx, attribution[layer_title], m2pt(cwidth-legend_item_box_size[0]-0.005), 6, fill_color=(0.5,0.5,0.5))
|
|
legend_text_size += e[3]
|
|
|
|
if legend_text_size > legend_entry_size:
|
|
legend_entry_size=legend_text_size
|
|
|
|
y+=legend_entry_size +2
|
|
if y > h:
|
|
h = y
|
|
return (w,h)
|