ocitysmap/ocitysmap2/index/render.py

518 wiersze
20 KiB
Python

# -*- coding: utf-8 -*-
# ocitysmap, city map and street index generator from OpenStreetMap data
# Copyright (C) 2010 David Decotigny
# Copyright (C) 2010 Frédéric Lehobey
# Copyright (C) 2010 Pierre Mauduit
# Copyright (C) 2010 David Mentré
# Copyright (C) 2010 Maxime Petazzoni
# Copyright (C) 2010 Thomas Petazzoni
# Copyright (C) 2010 Gaël Utard
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import cairo
import logging
import math
import pango
import pangocairo
import commons
l = logging.getLogger('ocitysmap')
class StreetIndexRenderingStyle:
"""
The StreetIndexRenderingStyle class defines how the header and
label items should be drawn (font family, size, etc.).
"""
__slots__ = ["header_font_spec", "label_font_spec"]
header_font_spec = None
label_font_spec = None
def __init__(self, header_font_spec, label_font_spec):
"""
Specify how the headers and label should be rendered. The
Pango Font Speficication strings below are of the form
"serif,monospace bold italic condensed 16". See
http://www.pygtk.org/docs/pygtk/class-pangofontdescription.html
for more details.
Args:
header_font_spec (str): Pango Font Specification for the headers.
label_font_spec (str): Pango Font Specification for the labels.
"""
self.header_font_spec = header_font_spec
self.label_font_spec = label_font_spec
def __str__(self):
return "Style(headers=%s, labels=%s)" % (repr(self.header_font_spec),
repr(self.label_font_spec))
class StreetIndexRenderingArea:
"""
The StreetIndexRenderingArea class describes the parameters of the
Cairo area and its parameters (fonts) where the index should be
renedered. It is basically returned by
StreetIndexRenderer::precompute_occupation_area() and used by
StreetIndexRenderer::render(). All its attributes x,y,w,h may be
used by the global map rendering engines.
"""
def __init__(self, street_index_rendering_style, x, y, w, h, n_cols):
"""
Describes the Cairo area to use when rendering the index.
Args:
street_index_rendering_style (StreetIndexRenderingStyle):
how to render the text inside the index
x (int): horizontal origin position (cairo units).
y (int): vertical origin position (cairo units).
w (int): width of area to use (cairo units).
h (int): height of area to use (cairo units).
n_cols (int): number of columns in the index.
"""
self.rendering_style = street_index_rendering_style
self.x, self.y, self.w, self.h, self.n_cols = x, y, w, h, n_cols
def __str__(self):
return "Area(%s, %dx%d+%d+%d, n_cols=%d)" \
% (self.rendering_style,
self.w, self.h, self.x, self.y, self.n_cols)
class StreetIndexRenderer:
"""
The StreetIndex class encapsulate all the logic related to the querying and
rendering of the street index.
"""
def __init__(self, i18n, index_categories,
street_index_rendering_styles \
= [ StreetIndexRenderingStyle('Georgia Bold 16',
'DejaVu 12'),
StreetIndexRenderingStyle('Georgia Bold 14',
'DejaVu 10'),
StreetIndexRenderingStyle('Georgia Bold 12',
'DejaVu 8'),
StreetIndexRenderingStyle('Georgia Bold 10',
'DejaVu 7'),
StreetIndexRenderingStyle('Georgia Bold 8',
'DejaVu 6'),
StreetIndexRenderingStyle('Georgia Bold 6',
'DejaVu 5'),
StreetIndexRenderingStyle('Georgia Bold 5',
'DejaVu 4'),
StreetIndexRenderingStyle('Georgia Bold 4',
'DejaVu 3'),
StreetIndexRenderingStyle('Georgia Bold 3',
'DejaVu 2'),
StreetIndexRenderingStyle('Georgia Bold 2',
'DejaVu 2'),
StreetIndexRenderingStyle('Georgia Bold 1',
'DejaVu 1'), ] ):
self._i18n = i18n
self._index_categories = index_categories
self._rendering_styles = street_index_rendering_styles
def precompute_occupation_area(self, surface, x, y, w, h,
freedom_direction, alignment):
"""Prepare to render the street and amenities index at the
given (x,y) coordinates into the provided Cairo surface. The
index must not be larger than the provided width and height
(in pixels). Nothing will be drawn on surface.
Args:
surface (cairo.Surface): the cairo surface to render into.
x (int): horizontal origin position, in pixels.
y (int): vertical origin position, in pixels.
w (int): maximum usable width for the index, in dots (Cairo unit).
h (int): maximum usable height for the index, in dots (Cairo unit).
freedom_direction (string): freedom direction, can be 'width' or
'height'. See _compute_columns_split for more details.
alignment (string): 'top' or 'bottom' for a freedom_direction
of 'height', 'left' or 'right' for 'width'. Tells which side to
stick the index to.
Returns the actual graphical StreetIndexRenderingArea defining
how and where the index should be rendered. Raise
IndexDoesNotFitError when the provided area's surface is not
enough to hold the index.
"""
if ((freedom_direction == 'height' and
alignment not in ('top', 'bottom')) or
(freedom_direction == 'width' and
alignment not in ('left', 'right'))):
raise ValueError, 'Incompatible freedom direction and alignment!'
if not self._index_categories:
raise commons.IndexEmptyError
l.debug("Determining inde area within %dx%d+%d+%d aligned %s/%s..."
% (w,h,x,y, alignment, freedom_direction))
# Create a PangoCairo context for drawing to Cairo
ctx = cairo.Context(surface)
pc = pangocairo.CairoContext(ctx)
# Iterate over the rendering_styles until we find a suitable layout
rendering_style = None
for rs in self._rendering_styles:
l.debug("Trying index fit using %s..." % rs)
try:
n_cols, min_dimension \
= self._compute_columns_split(pc, rs, w, h,
freedom_direction)
# Great: index did fit OK !
rendering_style = rs
break
except commons.IndexDoesNotFitError:
# Index did not fit => try smaller...
l.debug("Index %s too large: should try a smaller one."
% rs)
continue
# Index really did not fit with any of the rendering styles ?
if not rendering_style:
raise commons.IndexDoesNotFitError("Index does not fit in area")
# Realign at bottom/top left/right
if freedom_direction == 'height':
index_width = w
index_height = min_dimension
elif freedom_direction == 'width':
index_width = min_dimension
index_height = h
base_offset_x = 0
base_offset_y = 0
if alignment == 'bottom':
base_offset_y = h - index_height
if alignment == 'right':
base_offset_x = w - index_width
area = StreetIndexRenderingArea(rendering_style,
x+base_offset_x, y+base_offset_y,
index_width, index_height, n_cols)
l.debug("Will be able to render index in %s" % area)
return area
def render(self, surface, rendering_area):
"""Render the street and amenities index at the given (x,y) coordinates
into the provided Cairo surface. The index must not be larger than the
provided surface (use precompute_occupation_area() to adjust it).
Args:
surface (cairo.Surface): the cairo surface to render into.
rendering_area (StreetIndexRenderingArea): the result from
precompute_occupation_area().
"""
if not self._index_categories:
raise commons.IndexEmptyError
ctx = cairo.Context(surface)
ctx.move_to(rendering_area.x, rendering_area.y)
# Create a PangoCairo context for drawing to Cairo
pc = pangocairo.CairoContext(ctx)
header_fd = pango.FontDescription(rendering_area.rendering_style.header_font_spec)
label_fd = pango.FontDescription(rendering_area.rendering_style.label_font_spec)
label_layout, label_fascent, label_fheight, label_em = \
self._create_layout_with_font(pc, label_fd)
header_layout, header_fascent, header_fheight, header_em = \
self._create_layout_with_font(pc, header_fd)
cairo_colspace = label_em
column_width = int(math.floor(float(rendering_area.w
+ cairo_colspace)
/ rendering_area.n_cols))
label_layout.set_width((column_width - label_em) * pango.SCALE)
header_layout.set_width((column_width - label_em) * pango.SCALE)
if not self._i18n.isrtl():
delta_x = column_width
offset_x = 0
else:
delta_x = - column_width
offset_x = rendering_area.w - column_width + cairo_colspace
offset_y = 0
for category in self._index_categories:
if offset_y + header_fheight + label_fheight > rendering_area.h:
offset_y = 0
offset_x += delta_x
category.draw(self._i18n.isrtl(), ctx, pc, header_layout,
header_fascent, header_fheight,
rendering_area.x + offset_x,
rendering_area.y + offset_y + header_fascent)
offset_y += header_fheight
for street in category.items:
if offset_y + label_fheight > rendering_area.h:
offset_y = 0
offset_x += delta_x
street.draw(self._i18n.isrtl(), ctx, pc, label_layout,
label_fascent, label_fheight,
rendering_area.x + offset_x,
rendering_area.y + offset_y + label_fascent)
offset_y += label_fheight
def _create_layout_with_font(self, pc, font_desc):
layout = pc.create_layout()
layout.set_font_description(font_desc)
font = layout.get_context().load_font(font_desc)
font_metric = font.get_metrics()
fascent = font_metric.get_ascent() / pango.SCALE
fheight = ((font_metric.get_ascent() + font_metric.get_descent())
/ pango.SCALE)
em = font_metric.get_approximate_char_width() / pango.SCALE
return layout, fascent, fheight, em
def _compute_lines_occupation(self, pc, font_desc, n_em_padding,
text_lines):
"""Compute the visual dimension parameters of the initial long column
for the given text lines with the given font.
Args:
pc (pangocairo.CairoContext): the PangoCairo context.
font_desc (pango.FontDescription): Pango font description,
representing the used font at a given size.
n_em_padding (int): number of extra em space to account for.
text_lines (list): the list of text labels.
Returns a dictionnary with the following key,value pairs:
column_width: the computed column width (pixel size of the longest
label).
column_height: the total height of the column.
fascent: scaled font ascent.
fheight: scaled font height.
"""
layout, fascent, fheight, em = self._create_layout_with_font(pc,
font_desc)
width = max(map(lambda x: self._label_width(layout, x), text_lines))
# Save some extra space horizontally
width += n_em_padding * em
height = fheight * len(text_lines)
return {'column_width': width, 'column_height': height,
'fascent': fascent, 'fheight': fheight}
def _label_width(self, layout, label):
layout.set_text(label)
return layout.get_size()[0] / pango.SCALE
def _compute_column_occupation(self, pc, rendering_style):
"""Returns the size of the tall column with all headers, labels and
squares for the given font sizes.
Args:
pc (pangocairo.CairoContext): the PangoCairo context.
rendering_style (StreetIndexRenderingStyle): how to render the
headers and labels.
"""
header_fd = pango.FontDescription(rendering_style.header_font_spec)
label_fd = pango.FontDescription(rendering_style.label_font_spec)
# Account for maximum square width (at worst " " + "Z99-Z99")
label_block = self._compute_lines_occupation(pc, label_fd, 1+7,
reduce(lambda x,y: x+y.get_all_item_labels(),
self._index_categories, []))
# Reserve a small margin around the category headers
headers_block = self._compute_lines_occupation(pc, header_fd, 2,
[x.name for x in self._index_categories])
column_width = max(label_block['column_width'],
headers_block['column_width'])
column_height = label_block['column_height'] + \
headers_block['column_height']
return column_width, column_height, \
max(label_block['fheight'], headers_block['fheight'])
def _compute_columns_split(self, pc, rendering_style,
zone_width_dots, zone_height_dots,
freedom_direction):
"""Computes the columns split for this index. From the one tall column
width and height it finds the number of columns fitting on the zone
dedicated to the index on the Cairo surface.
If the columns split does not fit on the index zone,
commons.IndexDoesNotFitError is raised.
Args:
pc (pangocairo.CairoContext): the PangoCairo context.
rendering_style (StreetIndexRenderingStyle): how to render the
headers and labels.
zone_width_dots (float): maximum width of the Cairo zone dedicated
to the index.
zone_height_dots (float): maximum height of the Cairo zone
dedicated to the index.
freedom_direction (string): the zone dimension that is flexible for
rendering this index, can be 'width' or 'height'. If the
streets don't fill the zone dedicated to the index, we need to
try with a zone smaller in the freedom_direction.
Returns the number of columns that will be in the index and the new
value for the flexible dimension.
"""
tall_width, tall_height, vertical_extra = \
self._compute_column_occupation(pc, rendering_style)
if zone_width_dots < tall_width:
raise commons.IndexDoesNotFitError
if freedom_direction == 'height':
n_cols = math.floor(zone_width_dots / float(tall_width))
min_required_height = (math.ceil(tall_height / n_cols) +
vertical_extra)
if (n_cols <= 0 or n_cols * tall_width > zone_width_dots or
min_required_height > zone_height_dots):
raise commons.IndexDoesNotFitError
return int(n_cols), min_required_height
elif freedom_direction == 'width':
n_cols = math.ceil(float(tall_height) / zone_height_dots)
extra = n_cols * vertical_extra
min_required_width = n_cols * tall_width
if (min_required_width > zone_width_dots or
tall_height + extra > n_cols * zone_height_dots):
raise commons.IndexDoesNotFitError
return int(n_cols), min_required_width
raise ValueError, 'Invalid freedom direction!'
if __name__ == '__main__':
import random
import string
from ocitysmap2 import coords
from ocitysmap2.index import commons
import render
logging.basicConfig(level=logging.DEBUG)
width = 72*21./2.54
height = .75 * 72*29.7/2.54
random.seed(42)
bbox = coords.BoundingBox(48.8162, 2.3417, 48.8063, 2.3699)
surface = cairo.PDFSurface('/tmp/myindex_render.pdf', width, height)
def rnd_str(max_len, letters = string.letters):
return ''.join(random.choice(letters)
for i in xrange(random.randint(1, max_len)))
class i18nMock:
def __init__(self, rtl):
self.rtl = rtl
def isrtl(self):
return self.rtl
streets = []
for i in ['A', 'B', # 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', # 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'Schools', 'Public buildings']:
items = []
for label, location_str in [(rnd_str(10).capitalize(),
'%s%d-%s%d' \
% (rnd_str(2,
string.ascii_uppercase),
random.randint(1,19),
rnd_str(2,
string.ascii_uppercase),
random.randint(1,19),
))]*4:
item = commons.IndexItem(label, None, None)
item.location_str = location_str
items.append(item)
streets.append(commons.IndexCategory(i, items))
index = render.StreetIndexRenderer(i18nMock(False), streets)
def _render(freedom_dimension, alignment):
x,y,w,h = 50, 50, width-100, height-100
# Draw constraining rectangle
ctx = cairo.Context(surface)
ctx.set_source_rgb(.2,0,0)
ctx.rectangle(x,y,w,h)
ctx.stroke()
# Precompute index area
rendering_area = index.precompute_occupation_area(surface, x,y,w,h,
freedom_dimension,
alignment)
# Draw a green background for the precomputed area
ctx.set_source_rgba(0,1,0,.5)
ctx.rectangle(rendering_area.x, rendering_area.y,
rendering_area.w, rendering_area.h)
ctx.fill()
# Render the index
index.render(surface, rendering_area)
_render('height', 'top')
surface.show_page()
_render('height', 'bottom')
surface.show_page()
_render('width', 'left')
surface.show_page()
_render('width', 'right')
surface.show_page()
index = render.StreetIndexRenderer(i18nMock(True), streets)
_render('height', 'top')
surface.show_page()
_render('height', 'bottom')
surface.show_page()
_render('width', 'left')
surface.show_page()
_render('width', 'right')
surface.finish()
print "Generated /tmp/myindex_render.pdf"