[python-mapnik] 01/05: Imported Upstream version 0.0~20161104-ea5fd11
Bas Couwenberg
sebastic at debian.org
Sat Nov 5 15:21:18 UTC 2016
This is an automated email from the git hooks/post-receive script.
sebastic pushed a commit to branch master
in repository python-mapnik.
commit 6718a59ed5d6f5b3e58182d1b62d5d0e384488d3
Author: Bas Couwenberg <sebastic at xs4all.nl>
Date: Sat Nov 5 15:00:30 2016 +0100
Imported Upstream version 0.0~20161104-ea5fd11
---
.travis.yml | 1 +
README.md | 14 +-
mapnik/__init__.py | 3 -
mapnik/printing.py | 1226 -----------------
mapnik/printing/__init__.py | 1389 ++++++++++++++++++++
mapnik/printing/conversions.py | 17 +
mapnik/printing/formats.py | 74 ++
mapnik/printing/scales.py | 46 +
.../images/pycairo/pdf-printing-expected.pdf | Bin 0 -> 81792 bytes
test/python_tests/my.pdf | Bin 0 -> 7050 bytes
test/python_tests/pdf_printing_test.py | 55 +
11 files changed, 1592 insertions(+), 1233 deletions(-)
diff --git a/.travis.yml b/.travis.yml
index da486f6..92abdfe 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -76,6 +76,7 @@ before_install:
- pip install --upgrade --user wheel
- pip install --upgrade --user twine
- pip install --upgrade --user setuptools
+ - pip install --upgrade --user PyPDF2
- python --version
install:
diff --git a/README.md b/README.md
index fa3ba46..2549bbf 100644
--- a/README.md
+++ b/README.md
@@ -31,22 +31,28 @@ Assuming that you built your own mapnik from source, and you have run `make inst
python setup.py develop
```
-If you wish to are currently developing on mapnik-python and wish to change the code in place and immediately have python changes reflected in your environment.
+If you are currently developing on mapnik-python and wish to change the code in place and immediately have python changes reflected in your environment.
```
+python setup.py install
+```
+
+If you wish to just install the package.
+
+```
python setup.py develop --uninstall
```
Will de-activate the development install by removing the `python-mapnik` entry from `site-packages/easy-install.pth`.
+If you need Pycairo, make sure that PYCAIRO is set to true in your environment or run:
+
```
-python setup.py install
+PYCAIRO=true python setup.py develop
```
-If you wish to just install the package
-
## Testing
Once you have installed you can test the package by running:
diff --git a/mapnik/__init__.py b/mapnik/__init__.py
index 250970e..1d28848 100644
--- a/mapnik/__init__.py
+++ b/mapnik/__init__.py
@@ -73,9 +73,6 @@ bootstrap_env()
from ._mapnik import *
-from . import printing
-printing.renderer = render
-
# The base Boost.Python class
BoostPythonMetaclass = Coord.__class__
diff --git a/mapnik/printing.py b/mapnik/printing.py
deleted file mode 100644
index e2f975d..0000000
--- a/mapnik/printing.py
+++ /dev/null
@@ -1,1226 +0,0 @@
-# -*- 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, print_function
-
-import math
-import os
-import tempfile
-
-from . import (Box2d, Coord, Feature, Geometry, Layer, Map, Projection, Style,
- render)
-
-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 pyPdf.generic.NameObject('/OCProperties') in i.trailer['/Root']:
- 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 legend overflows this one
- collumns: number of columns 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 active_rules in added_styles:
- continue
-
- added_styles[active_rules] = (f, rule_text)
- if not item_per_rule:
- break
- else:
- added_styles[l] = (None, None)
-
- legend_items = sorted(added_styles.keys())
- 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 layer_title in attribution:
- 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)
diff --git a/mapnik/printing/__init__.py b/mapnik/printing/__init__.py
new file mode 100644
index 0000000..b9d06b1
--- /dev/null
+++ b/mapnik/printing/__init__.py
@@ -0,0 +1,1389 @@
+# -*- coding: utf-8 -*-
+
+"""Mapnik classes to assist in creating printable maps."""
+
+from __future__ import absolute_import, print_function
+
+import logging
+import math
+
+from mapnik import Box2d, Coord, Geometry, Layer, Map, Projection, Style, render
+from mapnik.printing.conversions import m2pt, m2px
+from mapnik.printing.formats import pagesizes
+from mapnik.printing.scales import any_scale, default_scale, deg_min_sec_scale, sequence_scale
+
+try:
+ import cairo
+except ImportError:
+ raise ImportError("Could not import pycairo; PDF rendering only available when pycairo is available")
+
+try:
+ import pangocairo
+ import pango
+ HAS_PANGOCAIRO_MODULE = True
+except ImportError:
+ HAS_PANGOCAIRO_MODULE = False
+
+try:
+ from PyPDF2 import PdfFileReader, PdfFileWriter
+ from PyPDF2.generic import (ArrayObject, DecodedStreamObject, DictionaryObject, FloatObject, NameObject,
+ NumberObject, TextStringObject)
+ HAS_PYPDF2 = True
+except ImportError:
+ HAS_PYPDF2 = False
+
+"""
+Style of centering to use with the map.
+
+CENTERING_NONE: map will be placed in the top left corner
+CENTERING_CONSTRAINED_AXIS: map will be centered on the most constrained axis (e.g. vertical for a portrait page); a square
+ map will be constrained horizontally
+CENTERING_UNCONSTRAINED_AXIS: map will be centered on the unconstrained axis
+CENTERING_VERTICAL: map will be centered vertically
+CENTERING_HORIZONTAL: map will be centered horizontally
+CENTERING_BOTH: map will be centered vertically and horizontally
+"""
+CENTERING_NONE = 0
+CENTERING_CONSTRAINED_AXIS = 1
+CENTERING_UNCONSTRAINED_AXIS = 2
+CENTERING_VERTICAL = 3
+CENTERING_HORIZONTAL = 4
+CENTERING_BOTH = 5
+
+# some predefined resolutions in DPI
+DPI_72 = 72
+DPI_150 = 150
+DPI_300 = 300
+DPI_600 = 600
+
+L = logging.getLogger("mapnik.printing")
+
+
+class PDFPrinter(object):
+
+ """
+ Main class for creating PDF print outs. 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")
+ """
+
+ def __init__(self,
+ pagesize=pagesizes["a4"],
+ margin=0.005,
+ box=None,
+ percent_box=None,
+ scale_function=default_scale,
+ resolution=DPI_72,
+ preserve_aspect=True,
+ centering=CENTERING_CONSTRAINED_AXIS,
+ is_latlon=False,
+ use_ocg_layers=False,
+ font_name="DejaVu Sans"):
+ """
+ Args:
+ pagesize: tuple of page size in meters, see predefined sizes in mapnik.formats module
+ margin: page margin in meters
+ box: the box to render the map into. Must be within page area, margin excluded.
+ This should be a Mapnik Box2d object. Default is the full page without margin.
+ percent_box: similar to box argument 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 used to render non vector elements (in DPI).
+ preserve_aspect: whether to preserve map aspect ratio or not. 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 it is
+ set to False.
+ centering: centering rules for maps where the scale rounding has reduced the map size. This should
+ be a value from the mapnik.utils.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: whether the map is in lat lon degrees or not.
+ use_ocg_layers: create OCG layers in the PDF, requires PyPDF2
+ font_name: the font name used each time text is written (e.g., legend titles, representative fraction, etc.)
+ """
+ self._pagesize = pagesize
+ self._margin = margin
+ self._box = box
+ self._resolution = resolution
+ self._centering = centering
+ self._is_latlon = is_latlon
+ self._use_ocg_layers = use_ocg_layers
+
+ self._surface = None
+ self._layer_names = []
+ self._filename = None
+
+ self.map_box = None
+
+ self.rounded_mapscale = None
+ self._scale_function = scale_function
+ self._preserve_aspect = preserve_aspect
+ if not preserve_aspect:
+ self._scale_function = 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])
+
+ self.font_name = font_name
+
+ def render_map(self, m, filename):
+ """Renders the given map to filename."""
+ self._surface = cairo.PDFSurface(filename, m2pt(self._pagesize[0]), m2pt(self._pagesize[1]))
+ ctx = cairo.Context(self._surface)
+
+ # store the output filename so that we can post-process the PDF
+ self._filename = filename
+
+ (eff_width, eff_height) = self._get_render_area_size()
+ (mapw, maph) = self._get_map_render_area_size(m, eff_width, eff_height)
+
+ # set the map pixel size so that raster elements render at specified resolution
+ m.resize(*self._get_map_pixel_size(mapw, maph))
+
+ (tx, ty) = self._get_render_corner((mapw, maph), m)
+
+ self._render_map_background(m, ctx, tx, ty)
+ self._render_layers_maps(m, ctx, tx, ty)
+
+ self.map_box = Box2d(tx, ty, tx + mapw, ty + maph)
+
+ def _get_render_area_size(self):
+ """Returns the width and height in meters of the page's render area."""
+ render_area = self._get_render_area()
+ return (render_area.width(), render_area.height())
+
+ def _get_render_area(self):
+ """Returns the page's area available for rendering. All dimensions are in meters."""
+ render_area = Box2d(
+ self._margin,
+ self._margin,
+ self._pagesize[0] -
+ self._margin,
+ self._pagesize[1] -
+ self._margin)
+
+ # if the user specified a box to render to, we take the intersection
+ # of that box with the page area available
+ if self._box:
+ return render_area.intersect(self._box)
+
+ return render_area
+
+ def _get_map_render_area_size(self, m, eff_width, eff_height):
+ """
+ Returns the render area for the map, i.e., a width and height in meters.
+ Preserves the map aspect by default.
+ """
+ scalefactor = self._get_map_scalefactor(m, eff_width, eff_height)
+ mapw = eff_width * scalefactor
+ maph = eff_height * scalefactor
+
+ page_aspect = eff_width / eff_height
+ map_aspect = m.envelope().width() / m.envelope().height()
+ if self._preserve_aspect:
+ if map_aspect > page_aspect:
+ maph = mapw * (1 / map_aspect)
+ else:
+ mapw = maph * map_aspect
+
+ return (mapw, maph)
+
+ def _get_map_scalefactor(self, m ,eff_width, eff_height):
+ """Returns the map scale factor based on effective render area size in meters."""
+ scalex = m.envelope().width() / eff_width
+ scaley = m.envelope().height() / eff_height
+ scale = max(scalex, scaley)
+ rounded_mapscale = self._scale_function(scale)
+ self.rounded_mapscale = rounded_mapscale
+ scalefactor = scale / rounded_mapscale
+
+ return scalefactor
+
+ def _get_map_pixel_size(self, width_page_m, height_page_m):
+ """
+ For a given map size in page coordinates, returns a tuple of the map
+ 'pixel' size based on the defined resolution.
+ """
+ return (int(m2px(width_page_m, self._resolution)),
+ int(m2px(height_page_m, self._resolution)))
+
+ def _get_render_corner(self, render_size, m):
+ """Returns the top left corner of the box we should render our map into."""
+ available_area = self._get_render_area()
+
+ x = available_area[0]
+ y = available_area[1]
+
+ if self._has_horizontal_centering(m):
+ x += (available_area.width() - render_size[0]) / 2
+
+ if self._has_vertical_centering(m):
+ y += (available_area.height() - render_size[1]) / 2
+ return (x, y)
+
+ def _has_horizontal_centering(self, m):
+ """Returns whether the map has an horizontal centering or not."""
+ is_map_size_constrained = self._is_map_size_constrained(m)
+
+ if (self._centering == CENTERING_BOTH or
+ self._centering == CENTERING_HORIZONTAL or
+ (self._centering == CENTERING_CONSTRAINED_AXIS and is_map_size_constrained) or
+ (self._centering == CENTERING_UNCONSTRAINED_AXIS and not is_map_size_constrained)):
+ return True
+ else:
+ return False
+
+ def _has_vertical_centering(self, m):
+ """Returns whether the map has a vertical centering or not."""
+ is_map_size_constrained = self._is_map_size_constrained(m)
+
+ if (self._centering == CENTERING_BOTH or
+ self._centering == CENTERING_VERTICAL or
+ (self._centering == CENTERING_CONSTRAINED_AXIS and not is_map_size_constrained) or
+ (self._centering == CENTERING_UNCONSTRAINED_AXIS and is_map_size_constrained)):
+ return True
+ else:
+ return False
+
+ def _is_map_size_constrained(self, m):
+ """Tests whether the map's 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 _render_map_background(self, m, ctx, tx, ty):
+ """
+ Renders the map background if there is one. If the user set use_ocg_layers to True, we put
+ the background in a separate layer.
+ """
+ if m.background or m.background_image or m.background_color:
+ background_map = Map(m.width,m.height,m.srs)
+ if m.background:
+ background_map.background = m.background
+ if m.background_image:
+ background_map.background_image = m.background_image
+ if m.background_color:
+ background_map.background_color = m.background_color
+
+ background_map.zoom_to_box(m.envelope())
+ self._render_layer_map(background_map, ctx, tx, ty)
+
+ if self._use_ocg_layers:
+ self._surface.show_page()
+ self._layer_names.append("Map Background")
+
+ def _render_layers_maps(self, m, ctx, tx, ty):
+ """Renders a layer as an individual map within a parent Map object."""
+ for layer in m.layers:
+ self._layer_names.append(layer.name)
+
+ layer_map = self._create_layer_map(m, layer)
+ self._render_layer_map(layer_map, ctx, tx, ty)
+
+ if self.map_spans_antimeridian(m):
+ 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))
+ self._render_layer_map(layer_map, ctx, tx, ty)
+ # restore the original env
+ m.zoom_to_box(old_env)
+
+ if self._use_ocg_layers:
+ self._surface.show_page()
+
+ def _create_layer_map(self, m, layer):
+ """
+ Instantiates and returns a Map object for the layer.
+ The layer Map has the parent Map dimensions.
+ """
+ layer_map = Map(m.width, m.height, m.srs)
+ layer_map.layers.append(layer)
+
+ for s in layer.styles:
+ layer_map.append_style(s, m.find_style(s))
+
+ layer_map.zoom_to_box(m.envelope())
+
+ return layer_map
+
+ def _render_layer_map(self, layer_map, ctx, tx, ty):
+ """Renders the layer map. Scales the cairo context to the specified resolution."""
+ ctx.save()
+ ctx.translate(m2pt(tx), m2pt(ty))
+ # cairo defaults to 72dpi
+ ctx.scale(72.0 / self._resolution, 72.0 / self._resolution)
+
+ # we clip the context to the map rectangle in order to restrict the background to that area
+ ctx.rectangle(0, 0, layer_map.width , layer_map.height)
+ ctx.clip()
+
+ render(layer_map, ctx)
+ ctx.restore()
+
+ def map_spans_antimeridian(self, m):
+ """Returns whether the map spans the antimeridian or not."""
+ if self._is_latlon and (m.envelope().minx < -180 or m.envelope().maxx > 180):
+ return True
+ else:
+ return False
+
+ def render_grid_on_map(self, m, grid_layer_name="Coordinates Grid Overlay"):
+ """
+ Adds a grid overlay on the map, i.e., horizontal and vertical axes plus boxes around the map.
+
+ Axes are drawn as 0.5px gray lines.
+ Boxes alternate between black fill / white stroke and white fill / black stroke. Font is DejaVu Sans.
+ """
+ (div_size, page_div_size) = self._get_sensible_scalebar_size(m)
+
+ # render horizontal axes
+ (first_value_x, first_value_x_percent) = self._get_scale_axes_first_values(
+ div_size,
+ m.envelope().minx,
+ m.envelope().width())
+ self._render_grid_axes_and_boxes_on_map(
+ first_value_x,
+ first_value_x_percent,
+ page_div_size,
+ div_size,
+ True)
+
+ # render vertical axes
+ (first_value_y, first_value_y_percent) = self._get_scale_axes_first_values(
+ div_size,
+ m.envelope().miny,
+ m.envelope().height())
+ self._render_grid_axes_and_boxes_on_map(
+ first_value_y,
+ first_value_y_percent,
+ page_div_size,
+ div_size,
+ False)
+
+ if self._use_ocg_layers:
+ self._surface.show_page()
+ self._layer_names.append(grid_layer_name)
+
+ def _get_sensible_scalebar_size(self, m, num_divisions=8, width=-1):
+ """
+ Returns a sensible scalebar size based on the map envelope, the number of divisions expected
+ in the scalebar, and optionally the width of the containing box.
+ """
+ div_size = sequence_scale(m.envelope().width() / num_divisions, [1, 2, 5])
+
+ # ensures we can fit the bar within page area width if specified
+ page_div_size = self.map_box.width() * div_size / m.envelope().width()
+ while width > 0 and page_div_size > width:
+ div_size /= 2.0
+ page_div_size /= 2.0
+
+ return (div_size, page_div_size)
+
+ def _get_scale_axes_first_values(self, div_size, map_envelope_start, map_envelope_side_length):
+ """
+ Returns the first value and the first value percent - how far is that value on the map side length -
+ for the scale axes.
+ """
+ first_value = (math.floor(map_envelope_start / div_size) + 1) * div_size
+ first_value_percent = (first_value - map_envelope_start) / map_envelope_side_length
+
+ return (first_value, first_value_percent)
+
+ def _render_grid_axes_and_boxes_on_map(self, first, first_percent, page_div_size, div_size, is_x_axis):
+ """
+ Renders the horizontal or vertical axes and corresponding boxes on the map depending on the is_x_axis
+ parameter.
+
+ Axes are drawn as 0.5px gray lines.
+ Boxes alternate between black fill / white stroke and white fill / black stroke. Font is DejaVu Sans.
+ """
+ ctx = cairo.Context(self._surface)
+
+ if is_x_axis:
+ (start, end, boundary_start, boundary_end) = self.map_box.minx, self.map_box.maxx, self.map_box.miny, self.map_box.maxy
+ else:
+ (start, end, boundary_start, boundary_end) = self.map_box.miny, self.map_box.maxy, self.map_box.minx, self.map_box.maxx
+
+ 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))
+
+ label_value = first - div_size
+ if self._is_latlon and label_value < -180:
+ label_value += 360
+
+ prev = start
+ text = None
+ black_rgb = (0.0, 0.0, 0.0)
+ fill_color = black_rgb
+ value = first_percent * (end - start) + start
+
+ while value < end:
+ self._draw_line(ctx, m2pt(value), m2pt(boundary_start), m2pt(value), m2pt(boundary_end), line_width=0.5)
+ self._render_grid_boxes(ctx, boundary_start, boundary_end, prev, value, text=text, fill_color=fill_color)
+
+ prev = value
+ value += page_div_size
+ fill_color = [1.0 - z for z in fill_color]
+ label_value += div_size
+ if self._is_latlon and label_value > 180:
+ label_value -= 360
+ text = "%d" % label_value
+ else:
+ # ensure that the last box gets drawn
+ self._render_grid_boxes(ctx, boundary_start, boundary_end, prev, end, fill_color=fill_color)
+
+ def _draw_line(self, ctx, start_x, start_y, end_x, end_y, line_width=1, stroke_color=(0.5, 0.5, 0.5)):
+ """
+ Draws a line from (start_x, start_y) to (end_x, end_y) on the specified cairo context.
+ By default, the line drawn is 1px wide and gray.
+ """
+ ctx.save()
+
+ ctx.move_to(start_x, start_y)
+ ctx.line_to(end_x, end_y)
+ ctx.set_source_rgb(*stroke_color)
+ ctx.set_line_width(line_width)
+ ctx.stroke()
+
+ ctx.restore()
+
+ def _render_grid_boxes(self, ctx, boundary_start, boundary_end, prev, value, text=None, border_size=8, fill_color=(0.0, 0.0, 0.0)):
+ """Renders the scale boxes at each end of the grid overlay."""
+ for bar in (m2pt(boundary_start) - border_size, m2pt(boundary_end)):
+ rectangle = Rectangle(m2pt(prev), bar, m2pt(value - prev), border_size)
+ self._render_box(ctx, rectangle, text, fill_color=fill_color)
+
+ def _render_box(self, ctx, rectangle, text=None, stroke_color=(0.0, 0.0, 0.0), fill_color=(1.0, 1.0, 1.0)):
+ """
+ Renders a box with top left corner positioned at (x,y).
+ Default design is white fill and black stroke.
+ """
+ ctx.save()
+
+ line_width = 1
+
+ ctx.set_line_width(line_width)
+ ctx.set_source_rgb(*fill_color)
+ ctx.rectangle(rectangle.x, rectangle.y, rectangle.width, rectangle.height)
+ ctx.fill()
+
+ ctx.set_source_rgb(*stroke_color)
+ ctx.rectangle(rectangle.x, rectangle.y, rectangle.width, rectangle.height)
+ ctx.stroke()
+
+ if text:
+ ctx.move_to(rectangle.x + 1, rectangle.y)
+ self.write_text(ctx, text, size=rectangle.height - 2, stroke_color=[1 - z for z in fill_color])
+
+ ctx.restore()
+
+ def write_text(self, ctx, text, box_width=None, size=10, stroke_color=(0.0, 0.0, 0.0), alignment=None):
+ """
+ Writes the text to the specified context.
+
+ Returns:
+ A rectangle (x, y, width, height) representing the extents of the text drawn
+ """
+ if HAS_PANGOCAIRO_MODULE:
+ return self._write_text_pangocairo(ctx, text, box_width=box_width, size=size, stroke_color=stroke_color, alignment=alignment)
+ else:
+ return self._write_text_cairo(ctx, text, size=size, stroke_color=stroke_color)
+
+ def _write_text_pangocairo(self, ctx, text, box_width=None, size=10, stroke_color=(0.0, 0.0, 0.0), alignment=None):
+ """
+ Use a pango.Layout object to write text to the cairo Context specified as a parameter.
+
+ Returns:
+ A rectangle (x, y, width, height) representing the extents of the pango layout as drawn
+ """
+ (attr, t, accel) = pango.parse_markup(text)
+ pctx = pangocairo.CairoContext(ctx)
+
+ pango_layout = pctx.create_layout()
+ pango_layout.set_attributes(attr)
+
+ fd = pango.FontDescription("%s %d" % (self.font_name, size))
+ pango_layout.set_font_description(fd)
+
+ if box_width:
+ pango_layout.set_width(int(box_width * pango.SCALE))
+ if alignment:
+ pango_layout.set_alignment(alignment)
+ pctx.update_layout(pango_layout)
+
+ pango_layout.set_text(t)
+ pctx.set_source_rgb(*stroke_color)
+ pctx.show_layout(pango_layout)
+
+ return pango_layout.get_pixel_extents()[0]
+
+ def _write_text_cairo(self, ctx, text, size=10, stroke_color=(0.0, 0.0, 0.0)):
+ """
+ Writes text to the cairo Context specified as a parameter.
+
+ Returns:
+ A rectangle (x, y, width, height) representing the extents of the text drawn
+ """
+ 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.set_source_rgb(*stroke_color)
+ ctx.show_text(text)
+
+ ctx.rel_move_to(0, size)
+
+ return (0, 0, len(text) * size, size)
+
+ def render_scale(self, m, ctx=None, width=0.05, num_divisions=3, bar_size=8.0, with_representative_fraction=True):
+ """
+ Renders two things:
+ - a scale bar
+ - a scale representative fraction just below it
+
+ Args:
+ m: the Map object to render the scale for
+ ctx: A cairo context to render the scale into. If this is None, we create a context and find out
+ the best location for the scale bar
+ width: the width of area available for rendering the scale bar (in meters)
+ num_divisions: the number of divisions for the scale bar
+ bar_size: the size of the scale bar in points
+ with_representative_fraction: whether we should render the representative fraction or not
+
+ Returns:
+ The size of the rendered scale block in points. (0, 0) if nothing is rendered.
+
+ Notes:
+ Does not render if lat lon maps or if the aspect ratio is not preserved.
+ The scale bar divisions alternate between black fill / white stroke and white fill / black stroke.
+ """
+ (w, h) = (0, 0)
+
+ # don't render scale text if we are in lat lon
+ # dont render scale text if we have warped the aspect ratio
+ if self._preserve_aspect and not self._is_latlon:
+
+ if ctx is None:
+ ctx = cairo.Context(self._surface)
+ (tx, ty) = self._get_meta_info_corner((self.map_box.width(), self.map_box.height()), m)
+ ctx.translate(m2pt(tx), m2pt(ty))
+
+ (w, h) = self._render_scale_bar(m, ctx, width, w, h, num_divisions, bar_size)
+
+ # renders the scale representative fraction text
+ if with_representative_fraction:
+ bar_to_fraction_space = 2
+ ctx.move_to(0, h + bar_to_fraction_space)
+
+ box_width = None
+ if width > 0:
+ box_width = m2pt(width)
+ h += self._render_scale_representative_fraction(ctx, box_width)
+
+ return (w, h)
+
+ def _render_scale_bar(self, m, ctx, width=0.05, w=0, h=0, num_divisions=3, bar_size=8.0):
+ """
+ Renders a graphic scale bar.
+
+ Returns:
+ The width and height of the scale bar rendered
+ """
+ # FIXME: bug. the scale bar divisions does not scale properly when the map envelope is huge
+ # to reproduce render python-mapnik/test/data/good_maps/agg_poly_gamma_map.xml and call render_scale
+
+ scale_bar_extra_space_factor = 1.2
+ div_width = width / num_divisions * scale_bar_extra_space_factor
+ (div_size, page_div_size) = self._get_sensible_scalebar_size(m, num_divisions=num_divisions, width=div_width)
+
+ div_unit = self.get_div_unit(div_size)
+
+ text = "0{}".format(div_unit)
+
+ ctx.save()
+ if width > 0:
+ ctx.translate(m2pt(width - num_divisions * page_div_size) / 2, 0)
+ for ii in range(num_divisions):
+ fill = (ii % 2,) * 3
+ rectangle = Rectangle(m2pt(ii*page_div_size), h, m2pt(page_div_size), bar_size)
+ self._render_box(ctx, rectangle, text, fill_color=fill)
+ fill = [1 - z for z in fill]
+ text = "{0}{1}".format((ii + 1) * div_size, div_unit)
+
+ w = (num_divisions) * page_div_size
+ h += bar_size
+ ctx.restore()
+
+ return (w, h)
+
+ def get_div_unit(self, div_size, div_unit_short="m", div_unit_long="km", div_unit_divisor=1000.0):
+ """
+ Returns the appropriate division unit based on the division size.
+
+ Args:
+ div_size: the size of the division
+ div_unit_short: the default string for the division unit
+ div_unit_long: the string for the division unit if div_size is large enough to be converted
+ from div_unit_short to div_unit_long while keeping div_size greater than 1
+ div_unit_divisor: the divisor applied to convert from div_unit_short to div_unit_long
+
+ Note:
+ Default values use the metric system
+ """
+ div_unit = div_unit_short
+ if div_size > div_unit_divisor:
+ div_size /= div_unit_divisor
+ div_unit = div_unit_long
+
+ return div_unit
+
+ def _render_scale_representative_fraction(self, ctx, box_width, box_width_padding=2, font_size=6):
+ """
+ Renders the scale text, i.e.
+
+ Returns:
+ The text extent width including padding.
+ """
+ if HAS_PANGOCAIRO_MODULE:
+ alignment = pango.ALIGN_CENTER
+ else:
+ alignment = None
+
+ text = "Scale 1:{}".format(int(self.rounded_mapscale))
+ text_extent = self.write_text(ctx, text, box_width=box_width, size=font_size, alignment=alignment)
+
+ text_extent_width = text_extent[3]
+
+ return text_extent_width + box_width_padding
+
+ def _get_meta_info_corner(self, render_size, m):
+ """
+ Returns 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)
+
+ render_box_padding_in_meters = 0.005
+ if self._is_map_size_constrained(m):
+ y += render_size[1] + render_box_padding_in_meters
+ x = self._margin
+ else:
+ x += render_size[0] + render_box_padding_in_meters
+ y = self._margin
+
+ return (x, y)
+
+ def render_graticule_on_map(self, m, dec_degrees=True, grid_layer_name="Graticule"):
+ # FIXME: buggy. does not get the top and right lines and other issues. see _render_graticule_axes_and_text also
+
+ """
+ Renders the graticule on the map.
+
+ Lines are drawn as 0.5px wide and gray.
+ Text font is DejaVu Sans and gray.
+ """
+
+ # 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())
+
+ # ensure that the projected map envelope is within the lat lon bounds and shift if necessary
+ latlon_bounds = self._adjust_latlon_bounds(m, p2, latlon_bounds)
+
+ 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:
+ # FIXME: what is the 7.0 magic number about?
+ latlon_divsize = default_scale(latlon_mapwidth / 7.0)
+ else:
+ # FIXME: what is the 7.0 magic number about?
+ latlon_divsize = deg_min_sec_scale(latlon_mapwidth / 7.0)
+ latlon_interpsize = latlon_mapwidth / m.width
+
+ # renders the horizontal graticule axes
+ self._render_graticule_axes_and_text(
+ m,
+ p2,
+ latlon_bounds,
+ latlon_buffer,
+ latlon_interpsize,
+ latlon_divsize,
+ dec_degrees,
+ True)
+
+ # renders the vertical graticule axes
+ self._render_graticule_axes_and_text(
+ m,
+ p2,
+ latlon_bounds,
+ latlon_buffer,
+ latlon_interpsize,
+ latlon_divsize,
+ dec_degrees,
+ False)
+
+ if self._use_ocg_layers:
+ self._surface.show_page()
+ self._layer_names.append(grid_layer_name)
+
+ def _adjust_latlon_bounds(self, m, proj, latlon_bounds):
+ """
+ Ensures that the projected map envelope is within the lat lon bounds.
+ If it's not, it shifts the lat lon bounds in the right direction by 360 degrees.
+
+ Returns:
+ The adjusted lat lon bounds box
+ """
+ if proj.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 proj.inverse(m.envelope().center()).y > latlon_bounds.maxy:
+ latlon_bounds = Box2d(
+ latlon_bounds.miny,
+ latlon_bounds.maxy,
+ latlon_bounds.maxx,
+ latlon_bounds.miny + 360)
+
+ return latlon_bounds
+
+ def _render_graticule_axes_and_text(self, m, p2, latlon_bounds, latlon_buffer,
+ latlon_interpsize, latlon_divsize, dec_degrees, is_x_axis, stroke_color=(0.5, 0.5, 0.5)):
+ # FIXME: buggy. does not get the top and right lines and other issues. see render_graticule_on_map also
+ """
+ Renders the horizontal or vertical axes on the map - depending on the is_x_axis parameter - along with
+ the latitude or longitude text.
+
+ Lines are drawn as 0.5px gray.
+ Text font is DejaVu Sans gray.
+ """
+
+ ctx = cairo.Context(self._surface)
+ ctx.set_source_rgb(*stroke_color)
+ 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(self.font_name, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
+ ctx.set_font_size(latlon_labelsize)
+
+ if is_x_axis:
+ (x1, x2, y1, y2) = latlon_bounds.minx, latlon_bounds.maxx, latlon_bounds.miny, latlon_bounds.maxy
+ box_top = self.map_box.height()
+ else:
+ (x1, x2, y1, y2) = latlon_bounds.miny, latlon_bounds.maxy, latlon_bounds.minx, latlon_bounds.maxx
+ 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 self.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)
+
+ self._draw_line(ctx, start.x, start.y, end.x, end.y, line_width=0.5)
+
+ 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 = self.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 round_grid_generator(self, first, last, step):
+ """Generator for lat lon grid values."""
+ val = (math.floor(first / step) + 1) * step
+ yield val
+ while val < last:
+ val += step
+ yield val
+
+ def format_deg_min_sec(self, value):
+ """Converts decimal degrees value to a degrees/minutes/seconds string."""
+ 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 render_legend(self, m, ctx=None, columns=2, width=None, height=None, attribution=None, legend_item_box_size=(0.015, 0.0075)):
+ """
+ Renders a legend for the Map object. A legend is a collection of legend items, i.e., a minified
+ representation of the layer's map along with the layer's title.
+
+ Args:
+ m: a Map object to render the legend for
+ ctx: a cairo context to render the legend to. If this is None then automatically create a context
+ and choose the best location for the legend.
+ width: width of area available to render legend in (in meters)
+ columns: number of columns 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
+
+ Returns:
+ The size of the rendered block in points.
+ """
+ render_box = Rectangle()
+ if self._surface:
+ if ctx is None:
+ ctx = cairo.Context(self._surface)
+ (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
+
+ column_width = None
+ if width:
+ column_width = width / columns
+ render_box.width = m2pt(width)
+
+ (render_box.width, render_box.height) = self._render_legend_items(m, ctx, render_box, column_width, height,
+ columns=columns, attribution=attribution, legend_item_box_size=legend_item_box_size)
+
+ return (render_box.width, render_box.height)
+
+ def _render_legend_items(self, m, ctx, render_box, column_width, height, columns=2, attribution=None, legend_item_box_size=(0.015, 0.0075)):
+ """Renders the legend items for the map."""
+ current_column = 0
+ processed_layers = []
+ for layer in reversed(m.layers):
+ have_layer_header = False
+ layer_title = layer.name
+ if layer_title in processed_layers:
+ continue
+ processed_layers.append(layer_title)
+
+ added_styles = self._get_unique_added_styles(m, layer)
+ legend_items = sorted(added_styles.keys())
+ for li in legend_items:
+ (f, rule_text) = added_styles[li]
+
+ legend_map_size = (int(m2pt(legend_item_box_size[0])), int(m2pt(legend_item_box_size[1])))
+ lemap = self._create_legend_item_map(m, layer, f, legend_map_size)
+
+ item_size = legend_map_size[1]
+ if not have_layer_header:
+ item_size += 8
+
+ # if we get to the bottom of the page, start a new column
+ # if we get to the max number of columns, start a new page
+ if render_box.y + item_size > m2pt(height):
+ current_column += 1
+ render_box.y = 0
+ if current_column >= columns:
+ self._surface.show_page()
+ render_box.x = 0
+ current_column = 0
+
+ self._render_legend_item_map(
+ lemap, legend_map_size, ctx, render_box.x, render_box.y, current_column, column_width)
+
+ ctx.move_to(
+ render_box.x + legend_map_size[0] + m2pt(current_column * column_width) + 2, render_box.y)
+
+ legend_entry_size = self._render_legend_item_text(
+ ctx, legend_map_size, legend_item_box_size, column_width, layer_title, attribution)
+
+ vertical_spacing = 5
+ render_box.y += legend_entry_size + vertical_spacing
+ if render_box.y > render_box.height:
+ render_box.height = render_box.y
+
+ return (render_box.width, render_box.height)
+
+ def _get_unique_added_styles(self, m, layer):
+ """
+ Go through the features to find which combinations of styles are active.
+ For each unique combination add a legend entry.
+ """
+ added_styles = {}
+ for f in layer.datasource.all_features():
+ if f.geometry:
+ active_rules = []
+ rule_text = ""
+ for s in layer.styles:
+ st = m.find_style(s)
+ for r in st.rules:
+ if self._is_rule_within_map_scale_limits(m, f, r):
+ active_rules.append((s, r.name))
+ rule_text = self._get_rule_text(r, rule_text)
+
+ active_rules = tuple(active_rules)
+ if active_rules in added_styles:
+ continue
+
+ added_styles[active_rules] = (f, rule_text)
+ break
+ else:
+ added_styles[layer] = (None, None)
+
+ return added_styles
+
+ def _is_rule_within_map_scale_limits(self, m, feature, rule):
+ """Returns whether the rule is within the map scale limits or not."""
+ if ((not rule.filter) or rule.filter.evaluate(feature) == '1') and \
+ rule.min_scale <= m.scale_denominator() and m.scale_denominator() < rule.max_scale:
+ return True
+ else:
+ return False
+
+ def _create_legend_item_map(self, m, layer, f, legend_map_size):
+ """Creates the legend map, i.e., a minified version of the layer map, and returns it."""
+ from mapnik import MemoryDatasource
+
+ legend_map = Map(legend_map_size[0], legend_map_size[1], srs=m.srs)
+
+ # the buffer is needed to ensure that text labels that overflow the edge of the
+ # map still render for the legend
+ legend_map.buffer_size = 1000
+ for layer_style in layer.styles:
+ lestyle = self._get_layer_style_valid_rules(m, layer_style)
+ legend_map.append_style(layer_style, lestyle)
+
+ ds = MemoryDatasource()
+ if f is None:
+ ds = layer.datasource
+ layer_srs = layer.srs
+ elif f.envelope().width() == 0:
+ f.geometry = Geometry.from_wkt('POINT (0 0)')
+ ds.add_feature(f)
+ legend_map.zoom_to_box(Box2d(-1, -1, 1, 1))
+ layer_srs = m.srs
+ else:
+ ds.add_feature(f)
+ layer_srs = layer.srs
+
+ lelayer = Layer("LegendLayer", layer_srs)
+ lelayer.datasource = ds
+ for layer_style in layer.styles:
+ lelayer.styles.append(layer_style)
+ legend_map.layers.append(lelayer)
+
+ if f is None or f.envelope().width() != 0:
+ legend_map.zoom_all()
+ legend_map.zoom(1.1)
+
+ return legend_map
+
+ def _get_layer_style_valid_rules(self, m, layer_style):
+ """Filters out the layer style rules that are not valid for the Map and returns the style."""
+ style = m.find_style(layer_style)
+ legend_style = Style()
+ for r in style.rules:
+ for sym in r.symbols:
+ try:
+ sym.avoid_edges = False
+ except AttributeError:
+ L.warning("Could not set avoid_edges for rule %s", r.name)
+ if r.min_scale <= m.scale_denominator() and m.scale_denominator() < r.max_scale:
+ legend_rule = r
+ legend_rule.min_scale = 0
+ legend_rule.max_scale = float("inf")
+ legend_style.rules.append(legend_rule)
+
+ return legend_style
+
+ def _render_legend_item_map(self, lemap, legend_map_size, ctx, x, y, current_column, column_width, stroke_color=(0.5, 0.5, 0.5), line_width=1):
+ """Renders the legend item map."""
+ ctx.save()
+ ctx.translate(x + m2pt(current_column * column_width), 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(*stroke_color)
+ ctx.set_line_width(line_width)
+ ctx.stroke()
+ ctx.restore()
+
+ def _render_legend_item_text(self, ctx, legend_map_size, legend_item_box_size, column_width, layer_title, attribution=None):
+ """
+ Renders the legend item text next to the legend item box.
+
+ Returns:
+ The size of the legend entry size, i.e., the legend box height or
+ the legend text height depending on which one takes more vertical
+ space.
+ """
+ gray_rgb = (0.5, 0.5, 0.5)
+ legend_box_padding_in_meters = 0.005
+ legend_box_width = m2pt(column_width - legend_item_box_size[0] - legend_box_padding_in_meters)
+
+ legend_entry_size = legend_map_size[1]
+ legend_text_size = 0
+
+ rule_text = layer_title
+ if rule_text:
+ e = self.write_text(ctx, rule_text, box_width=legend_box_width, size=6)
+ legend_text_size += e[3]
+ ctx.rel_move_to(0, e[3])
+ if attribution:
+ if layer_title in attribution:
+ e = self.write_text(
+ ctx,
+ attribution[layer_title],
+ box_width=legend_box_width,
+ size=6,
+ stroke_color=gray_rgb)
+ legend_text_size += e[3]
+
+ if legend_text_size > legend_entry_size:
+ legend_entry_size = legend_text_size
+
+ return legend_entry_size
+
+ def _get_rule_text(self, rule, rule_text):
+ """Returns the rule text."""
+ if rule.filter and str(rule.filter) != "true":
+ if len(rule_text) > 0:
+ rule_text += " AND "
+ if rule.name:
+ rule_text += rule.name
+ else:
+ rule_text += str(rule.filter)
+
+ return rule_text
+
+ def finish(self):
+ """
+ Finishes the cairo surface and converts PDF pages to PDF layers if
+ _use_ocg_layers was set to True.
+ """
+ if self._surface:
+ self._surface.finish()
+ self._surface = None
+
+ if self._use_ocg_layers:
+ self.convert_pdf_pages_to_layers(
+ self._filename,
+ layer_names=self._layer_names +
+ ["Legend and Information"],
+ reverse_all_but_last=True)
+
+ def convert_pdf_pages_to_layers(self, filename, layer_names=None, reverse_all_but_last=True):
+ """
+ Takes a multi pages PDF as input and converts each page to a layer in a single page PDF.
+
+ Note:
+ requires PyPDF2 to be available
+
+ Args:
+ 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.
+ """
+ if not HAS_PYPDF2:
+ raise RuntimeError("PyPDF2 not available; PyPDF2 required to convert pdf pages to layers")
+
+ with open(filename, "rb+") as f:
+ file_reader = PdfFileReader(f)
+ file_writer = PdfFileWriter()
+
+ template_page_size = file_reader.pages[0].mediaBox
+ output_pdf = file_writer.addBlankPage(
+ width=template_page_size.getWidth(),
+ height=template_page_size.getHeight())
+
+ content_key = NameObject('/Contents')
+ output_pdf[content_key] = ArrayObject()
+
+ resource_key = NameObject('/Resources')
+ output_pdf[resource_key] = DictionaryObject()
+
+ (properties, ocgs) = self._make_ocg_layers(file_reader, file_writer, output_pdf, layer_names)
+
+ properties_key = NameObject('/Properties')
+ output_pdf[resource_key][properties_key] = file_writer._addObject(properties)
+
+ ocproperties = DictionaryObject()
+ ocproperties[NameObject('/OCGs')] = ocgs
+
+ default_view = self._get_pdf_default_view(ocgs, reverse_all_but_last)
+ ocproperties[NameObject('/D')] = file_writer._addObject(default_view)
+
+ file_writer._root_object[NameObject('/OCProperties')] = file_writer._addObject(ocproperties)
+
+ f.seek(0)
+ file_writer.write(f)
+ f.truncate()
+
+ def _make_ocg_layers(self, file_reader, file_writer, output_pdf, layer_names=None):
+ """
+ Makes the OCGs layers.
+
+ Returns:
+ properties: a dictionary mapping the OCG layer name and the OCG layer property list
+ ocgs: an array containing the OCG layers
+ """
+ properties = DictionaryObject()
+ ocgs = ArrayObject()
+
+ for (idx, page) in enumerate(file_reader.pages):
+ # first start an OCG for the layer
+ ocg_name = NameObject('/oc%d' % idx)
+ ocgs_start = DecodedStreamObject()
+ ocgs_start._data = "/OC %s BDC\n" % ocg_name
+ ocg_end = DecodedStreamObject()
+ ocg_end._data = "EMC\n"
+
+ if isinstance(page['/Contents'], ArrayObject):
+ page[NameObject('/Contents')].insert(0, ocgs_start)
+ page[NameObject('/Contents')].append(ocg_end)
+ else:
+ page[NameObject(
+ '/Contents')] = ArrayObject((ocgs_start, page['/Contents'], ocg_end))
+
+ output_pdf.mergePage(page)
+
+ ocg = DictionaryObject()
+ ocg[NameObject('/Type')] = NameObject('/OCG')
+
+ if layer_names and len(layer_names) > idx:
+ ocg[NameObject('/Name')] = TextStringObject(layer_names[idx])
+ else:
+ ocg[NameObject('/Name')] = TextStringObject('Layer %d' % (idx + 1))
+
+ indirect_ocg = file_writer._addObject(ocg)
+ properties[ocg_name] = indirect_ocg
+ ocgs.append(indirect_ocg)
+
+ return (properties, ocgs)
+
+ def _get_pdf_default_view(self, ocgs, reverse_all_but_last=True):
+ """
+ Returns the D configuration dictionary of the PDF.
+
+ The D configuration dictionary specifies the initial state of the optional content
+ groups when a PDF is first opened.
+ """
+ default_view = DictionaryObject()
+ default_view[NameObject('/Name')] = TextStringObject('Default')
+ default_view[NameObject('/BaseState ')] = NameObject('/ON ')
+ default_view[NameObject('/ON')] = ocgs
+ default_view[NameObject('/OFF')] = ArrayObject()
+
+ if reverse_all_but_last:
+ default_view[NameObject('/Order')] = ArrayObject(reversed(ocgs[:-1]))
+ default_view[NameObject('/Order')].append(ocgs[-1])
+ else:
+ default_view[NameObject('/Order')] = ArrayObject(reversed(ocgs))
+
+ return default_view
+
+ def add_geospatial_pdf_header(self, m, filename, epsg=None, wkt=None):
+ """
+ Adds geospatial PDF information to the PDF file as per:
+ Adobe® Supplement to the ISO 32000 PDF specification
+ BaseVersion: 1.7
+ ExtensionLevel: 3
+ (June 2008)
+
+ Notes:
+ The epsg code or the wkt text of the projection must be provided.
+ Must be called *after* the page has had .finish() called.
+ """
+ if not HAS_PYPDF2:
+ raise RuntimeError("PyPDF2 not available; PyPDF2 required to add geospatial header to PDF")
+
+ if not any((epsg,wkt)):
+ raise RuntimeError("EPSG or WKT required to add geospatial header to PDF")
+
+ with open(filename, "rb+") as f:
+ file_reader = PdfFileReader(f)
+ file_writer = PdfFileWriter()
+
+ # preserve OCProperties at document root if we have one
+ if file_reader.trailer['/Root'].has_key(NameObject('/OCProperties')):
+ file_writer._root_object[NameObject('/OCProperties')] = file_reader.trailer[
+ '/Root'].getObject()[NameObject('/OCProperties')]
+
+ for page in file_reader.pages:
+ gcs = DictionaryObject()
+ gcs[NameObject('/Type')] = NameObject('/PROJCS')
+
+ if epsg:
+ gcs[NameObject('/EPSG')] = NumberObject(int(epsg))
+ if wkt:
+ gcs[NameObject('/WKT')] = TextStringObject(wkt)
+
+ measure = self._get_pdf_measure(m, gcs)
+ page[NameObject('/VP')] = self._get_pdf_vp(measure)
+
+ file_writer.addPage(page)
+
+ f.seek(0)
+ file_writer.write(f)
+ f.truncate()
+
+ def _get_pdf_measure(self, m, gcs):
+ """
+ Returns the PDF Measure dictionary.
+
+ The Measure dictionary is used in the viewport array
+ and specifies the scale and units that apply to the output map.
+ """
+ measure = DictionaryObject()
+ measure[NameObject('/Type')] = NameObject('/Measure')
+ measure[NameObject('/Subtype')] = NameObject('/GEO')
+ measure[NameObject('/GCS')] = gcs
+
+ bounds = self._get_pdf_bounds()
+ measure[NameObject('/Bounds')] = bounds
+ measure[NameObject('/LPTS')] = bounds
+
+ measure[NameObject('/GPTS')] = self._get_pdf_gpts(m)
+
+ return measure
+
+ def _get_pdf_bounds(self):
+ """
+ Returns the PDF BOUNDS array.
+
+ The PDF's bounds array is equivalent to the map's neatline, i.e.,
+ the border delineating the extent of geographic data on the output map.
+ """
+ bounds = ArrayObject()
+
+ # PDF specification's default for bounds (full unit square)
+ bounds_default = (0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0)
+
+ for x in bounds_default:
+ bounds.append(FloatObject(str(x)))
+
+ return bounds
+
+ def _get_pdf_gpts(self, m):
+ """
+ Returns the GPTS array object containing the four corners of the
+ map envelope in map projection.
+
+ The GPTS entry is an array of numbers, taken pairwise, defining
+ points as latitude and longitude.
+ """
+ gpts = 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 specification
+ gpts.append(FloatObject(str(latlon_corner.y)))
+ gpts.append(FloatObject(str(latlon_corner.x)))
+
+ return gpts
+
+ def _get_pdf_vp(self, measure):
+ """
+ Returns the PDF's VP array.
+
+ The VP entry is an array of viewport dictionaries. A viewport is basiscally
+ a rectangular region on the PDF page. The only required entry is the BBox which
+ specifies the location of the viewport on the page.
+ """
+ viewport = DictionaryObject()
+ viewport[NameObject('/Type')] = NameObject('/Viewport')
+
+ bbox = ArrayObject()
+ for x in self.map_box:
+ # this should be converted from meters to points
+ # fix submitted in https://github.com/mapnik/python-mapnik/pull/115
+ bbox.append(FloatObject(str(x)))
+
+ viewport[NameObject('/BBox')] = bbox
+ viewport[NameObject('/Measure')] = measure
+
+ vp_array = ArrayObject()
+ vp_array.append(viewport)
+
+ return vp_array
+
+ def get_width(self):
+ """Returns page's width."""
+ return self._pagesize[0]
+
+ def get_height(self):
+ """Returns page's height."""
+ return self._pagesize[1]
+
+ def get_margin(self):
+ """Returns page's margin."""
+ return self._margin
+
+ def get_cairo_context(self):
+ """
+ Allows access to the cairo Context so that extra 'bits'
+ can be rendered to the page directly.
+ """
+ return cairo.Context(self._surface)
+
+
+class Rectangle(object):
+
+ def __init__(self, x=0, y=0, width=0, height=0):
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+
+ def __repr__(self):
+ return "({}, {}, {}, {})".format(self.x, self.y, self.width, self.height)
+
+ def origin(self):
+ """Returns the top left corner coordinates in pdf points."""
+ return (self.x, self.y)
diff --git a/mapnik/printing/conversions.py b/mapnik/printing/conversions.py
new file mode 100644
index 0000000..c08c5e8
--- /dev/null
+++ b/mapnik/printing/conversions.py
@@ -0,0 +1,17 @@
+"""Unit conversion helpers."""
+
+def m2pt(x, pt_size=0.0254/72.0):
+ """Converts distance from meters to points. Default value is PDF point size."""
+ return x / pt_size
+
+def pt2m(x, pt_size=0.0254/72.0):
+ """Converts distance from points to meters. Default value is PDF point size."""
+ return x * pt_size
+
+def m2in(x):
+ """Converts distance from meters to inches."""
+ return x / 0.0254
+
+def m2px(x, resolution):
+ """Converts distance from meters to pixels at the given resolution in DPI/PPI."""
+ return m2in(x) * resolution
diff --git a/mapnik/printing/formats.py b/mapnik/printing/formats.py
new file mode 100644
index 0000000..e2b3a3c
--- /dev/null
+++ b/mapnik/printing/formats.py
@@ -0,0 +1,74 @@
+"""Some predefined page sizes 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),
+}
diff --git a/mapnik/printing/scales.py b/mapnik/printing/scales.py
new file mode 100644
index 0000000..2cb0db2
--- /dev/null
+++ b/mapnik/printing/scales.py
@@ -0,0 +1,46 @@
+"""Scale helpers functions."""
+
+import math
+
+
+def any_scale(scale):
+ """Scale helper function that allows any scale."""
+ return scale
+
+def sequence_scale(scale, scale_sequence):
+ """Sequence 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
diff --git a/test/python_tests/images/pycairo/pdf-printing-expected.pdf b/test/python_tests/images/pycairo/pdf-printing-expected.pdf
new file mode 100644
index 0000000..e0dedea
Binary files /dev/null and b/test/python_tests/images/pycairo/pdf-printing-expected.pdf differ
diff --git a/test/python_tests/my.pdf b/test/python_tests/my.pdf
new file mode 100644
index 0000000..7d80dfd
Binary files /dev/null and b/test/python_tests/my.pdf differ
diff --git a/test/python_tests/pdf_printing_test.py b/test/python_tests/pdf_printing_test.py
new file mode 100644
index 0000000..fa2af93
--- /dev/null
+++ b/test/python_tests/pdf_printing_test.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python
+
+import os
+
+from nose.tools import eq_
+
+import mapnik
+from .utilities import execution_path, run_all
+
+def setup():
+ # All of the paths used are relative, if we run the tests
+ # from another directory we need to chdir()
+ os.chdir(execution_path('.'))
+
+def make_map_from_xml(source_xml):
+ m = mapnik.Map(100, 100)
+ mapnik.load_map(m, source_xml, True)
+ m.zoom_all()
+
+ return m
+
+def make_pdf(m, output_pdf, esri_wkt):
+ # renders a PDF with a grid and a legend
+ page = mapnik.printing.PDFPrinter(use_ocg_layers=True)
+
+ page.render_map(m, output_pdf)
+ page.render_grid_on_map(m)
+ page.render_legend(m)
+
+ page.finish()
+ page.add_geospatial_pdf_header(m, output_pdf, wkt=esri_wkt)
+
+if mapnik.has_pycairo():
+ def test_pdf_printing():
+ source_xml = '../data/good_maps/marker-text-line.xml'.encode('utf-8')
+ m = make_map_from_xml(source_xml)
+
+ actual_pdf = "/tmp/pdf-printing-actual.pdf"
+ esri_wkt = 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]'
+ make_pdf(m, actual_pdf, esri_wkt)
+
+ expected_pdf = 'images/pycairo/pdf-printing-expected.pdf'
+
+ diff = abs(os.stat(expected_pdf).st_size - os.stat(actual_pdf).st_size)
+ msg = 'diff in size (%s) between actual (%s) and expected(%s)' % (diff, actual_pdf, 'tests/python_tests/' + expected_pdf)
+ eq_(diff < 1500, True, msg)
+
+# TODO: ideas for further testing on printing module
+# - test with and without pangocairo
+# - test legend with attribution
+# - test graticule (bug at the moment)
+
+if __name__ == "__main__":
+ setup()
+ exit(run_all(eval(x) for x in dir() if x.startswith("test_")))
--
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-grass/python-mapnik.git
More information about the Pkg-grass-devel
mailing list