import math
import base64
import shapely.ops
import shapely.geometry
from PIL import ImageFont
import wand.image
import gws
import gws.tools.units as units
import gws.tools.xml2 as xml2
import gws.gis.extent
import gws.gis.renderview
import gws.tools.style
import gws.types as t
DEFAULT_FONT_SIZE = 10
DEFAULT_MARKER_SIZE = 10
DEFAULT_POINT_SIZE = 10
SVG_ATTRIBUTES = {
'version': '1.1',
'xmlns': 'http://www.w3.org/2000/svg',
}
[docs]def sort_by_z_index(tags: t.List[t.Tag]):
def key(tag):
for e in tag:
if isinstance(e, dict) and 'z-index' in e:
return e['z-index']
return 0
tags.sort(key=key)
[docs]def as_xml(tags: t.List[t.Tag]) -> str:
return ''.join(gws.tools.xml2.as_string(tag) for tag in tags)
[docs]def as_png(tags: t.List[t.Tag], size: t.Size) -> bytes:
sort_by_z_index(tags)
svg = gws.tools.xml2.as_string(('svg', SVG_ATTRIBUTES, *tags))
with wand.image.Image(blob=svg.encode('utf8'), format='svg', background='None', width=size[0], height=size[1]) as image:
return image.make_blob('png')
##
def _pixel_transform(rv: t.MapRenderView):
def translate(x, y):
x = x - rv.bounds.extent[0]
y = rv.bounds.extent[3] - y
return (
units.mm2px_f((x / rv.scale) * 1000, rv.dpi),
units.mm2px_f((y / rv.scale) * 1000, rv.dpi))
def rotate(x, y):
return (
cosa * (x - ox) - sina * (y - oy) + ox,
sina * (x - ox) + cosa * (y - oy) + oy)
def fn(x, y):
x, y = translate(x, y)
if rv.rotation:
x, y = rotate(x, y)
return x, y
ox, oy = translate(*gws.gis.extent.center(rv.bounds.extent))
cosa = math.cos(math.radians(rv.rotation))
sina = math.sin(math.radians(rv.rotation))
return fn
def _marker(uid, sv) -> t.Tag:
size = sv.marker_size or DEFAULT_MARKER_SIZE
size2 = size // 2
content = None
atts = {}
_fill_stroke_props(atts, sv, 'marker_')
if sv.marker == 'circle':
atts.update({
'cx': size2,
'cy': size2,
'r': size2,
})
content = 'circle', atts
if content:
return 'marker', {
'id': uid,
'viewBox': f'0 0 {size} {size}',
'refX': size2,
'refY': size2,
'markerUnits': 'userSpaceOnUse',
'markerWidth': size,
'markerHeight': size,
}, content
def _label(geom, label, sv, extra_y_offset=0) -> t.Tag:
cx, cy = _label_position(geom, sv, extra_y_offset)
return _text(cx, cy, label, sv)
def _label_position(geom, sv, extra_y_offset=0):
if sv.label_placement == 'start':
x, y = _get_points(geom)[0]
elif sv.label_placement == 'end':
x, y = _get_points(geom)[-1]
else:
c = geom.centroid
x, y = c.x, c.y
return (
round(x) + (sv.label_offset_x or 0),
round(y) + extra_y_offset + (sv.label_font_size >> 1) + (sv.label_offset_y or 0)
)
def _text(cx, cy, label, sv) -> t.Tag:
# @TODO label positioning needs more work
font_name = _map_font(sv)
font_size = sv.label_font_size or DEFAULT_FONT_SIZE
font = ImageFont.truetype(font=font_name, size=font_size)
anchor = 'start'
if sv.label_align == 'right':
anchor = 'end'
elif sv.label_align == 'center':
anchor = 'middle'
atts = {'text-anchor': anchor}
_font_props(atts, sv, 'label_')
_fill_stroke_props(atts, sv, 'label_')
lines = list(gws.lines(label))
_, em_height = font.getsize('MMM')
metrics = [font.getsize(s) for s in lines]
line_height = sv.label_line_height or 1
padding = sv.label_padding or [0, 0, 0, 0]
ly = cy - padding[2]
lx = cx
if anchor == 'start':
lx += padding[3]
elif anchor == 'end':
lx -= padding[1]
else:
lx += padding[3] // 2
height = em_height * len(lines) + line_height * (len(lines) - 1) + padding[0] + padding[2]
pad_bottom = metrics[-1][1] - em_height
if pad_bottom > 0:
height += pad_bottom
ly -= pad_bottom
spans = []
for s in reversed(lines):
spans.append(xml2.tag('tspan', {'x': lx, 'y': ly}, s))
ly -= (em_height + line_height)
tags = [xml2.tag('text', atts, *reversed(spans))]
# @TODO a hack to emulate 'paint-order' which wkhtmltopdf doesn't seem to support
# place a copy without the stroke above above the text
if atts.get('stroke'):
no_stroke_atts = {k: v for k, v in atts.items() if not k.startswith('stroke')}
tags.append(xml2.tag('text', no_stroke_atts, *reversed(spans)))
if sv.label_background:
width = max(xy[0] for xy in metrics) + padding[1] + padding[3]
if anchor == 'start':
bx = cx
elif anchor == 'end':
bx = cx - width
else:
bx = cx - width // 2
ratts = {
'x': bx,
'y': cy - height,
'width': width,
'height': height,
'fill': sv.label_background,
}
tags.insert(0, xml2.tag('rect', ratts))
# a hack to move labels forward: emit a (non-supported) z-index attribute
# and sort elements by it later on
return xml2.tag('g', {'z-index': 100}, *tags)
_PFX_SVF_UTF8 = 'data:image/svg+xml;utf8,'
_PFX_SVF_BASE64 = 'data:image/svg+xml;base64,'
def _parse_icon_url(url, dpi):
src = None
if url.startswith(_PFX_SVF_UTF8):
src = url[len(_PFX_SVF_UTF8):]
elif url.startswith(_PFX_SVF_BASE64):
src = base64.standard_b64decode(url[len(_PFX_SVF_BASE64):]).decode('utf8')
if not src:
return
try:
svg = xml2.from_string(src)
except xml2.Error as e:
gws.log.error(f'error parsing icon url: {e}')
return
w = svg.attr('width')
h = svg.attr('height')
if not w or not h:
return
try:
w, wu = units.parse(w, units=['px', 'mm'], default='px')
h, hu = units.parse(h, units=['px', 'mm'], default='px')
except ValueError:
return
if wu == 'mm':
w = units.mm2px(w, dpi)
if hu == 'mm':
h = units.mm2px(h, dpi)
sub = gws.compact(_clean(c) for c in svg.children)
return xml2.tag('svg', *(c.as_tag() for c in sub)), w, h
_ALLOWED_TAGS = {
'circle',
'clippath',
'defs',
'ellipse',
'g',
'hatch',
'hatchpath',
'line',
'lineargradient',
'marker',
'mask',
'mesh',
'meshgradient',
'meshpatch',
'meshrow',
'mpath',
'path',
'pattern',
'polygon',
'polyline',
'radialgradient',
'rect',
'solidcolor',
'symbol',
'text',
'textpath',
'title',
'tspan',
'use',
}
def _clean(el: xml2.Element) -> t.Optional[xml2.Element]:
# @TODO: security! since icons are arbitrary content, check for valid tag names and values
if el.name not in _ALLOWED_TAGS:
return
el.attributes = gws.compact(_clean_attr(a) for a in el.attributes)
el.children = gws.compact(_clean(c) for c in el.children)
return el
def _clean_attr(a: xml2.Attribute):
if a.value.startswith(('http:', 'https:', 'data:')):
return
return a
def _icon_size_position(geom, sv, width, height):
# @TODO style options for icon positioning
c = geom.centroid
return (
int(c.x - width / 2),
int(c.y - height / 2),
width,
height)
def _get_points(geom):
gt = geom.type
if gt in ('Point', 'LineString', 'LinearRing'):
return geom.coords
if gt in ('Polygon', 'LineString', 'LinearRing'):
return geom.exterior.coords
if gt.startswith('Multi'):
return [p for g in geom.geoms for p in _get_points(g)]
def _fill_stroke_props(atts, sv, prefix=''):
atts['fill'] = sv.get(prefix + 'fill') or 'none'
v = sv.get(prefix + 'stroke')
if not v:
return
atts['stroke'] = v
v = sv.get(prefix + 'stroke_width')
atts['stroke-width'] = f'{v or 1}px'
v = sv.get(prefix + 'stroke_dasharray')
if v:
atts['stroke-dasharray'] = ' '.join(str(x) for x in v)
for k in 'dashoffset', 'linecap', 'linejoin', 'miterlimit':
v = sv.get(prefix + 'stroke_' + k)
if v:
atts['stroke-' + k] = v
def _font_props(atts, sv, prefix=''):
font_name = _map_font(sv, prefix)
font_size = sv.get(prefix + 'font_size') or DEFAULT_FONT_SIZE
atts.update(gws.compact({
'font-family': font_name.split('-')[0],
'font-size': f'{font_size}px',
'font-weight': sv.get(prefix + 'font_weight'),
'font-style': sv.get(prefix + 'font_style'),
}))
def _map_font(sv, prefix=''):
# @TODO: allow for more fonts and customize the mapping
DEFAULT_FONT = 'DejaVuSans'
w = sv.get(prefix + 'font_weight')
if w == 'bold':
return DEFAULT_FONT + '-Bold'
return DEFAULT_FONT
def _geometry(geom: shapely.geometry.base.BaseGeometry) -> t.Tag:
def _xy(xy):
return str(xy[0]) + ' ' + str(xy[1])
def _lpath(coords):
ps = []
cs = iter(coords)
for c in cs:
ps.append(f'M {_xy(c)}')
break
for c in cs:
ps.append(f'L {_xy(c)}')
return ' '.join(ps)
ty = geom.type.lower()
if ty == 'point':
g = t.cast(shapely.geometry.Point, geom)
return xml2.tag('circle', {'cx': g.x, 'cy': g.y})
if ty == 'linestring':
g = t.cast(shapely.geometry.LineString, geom)
d = _lpath(g.coords)
return xml2.tag('path', {'d': d})
if ty == 'polygon':
g = t.cast(shapely.geometry.Polygon, geom)
d = ' '.join(_lpath(interior.coords) + ' z' for interior in g.interiors)
d = _lpath(g.exterior.coords) + ' z ' + d
return xml2.tag('path', {'fill-rule': 'evenodd', 'd': d.strip()})
if ty in ('multipolygon', 'multipoint', 'mutlilinestring'):
g = t.cast(shapely.geometry.base.BaseMultipartGeometry, geom)
return xml2.tag('g', *[_geometry(p) for p in g.geoms])
raise ValueError(f'unknown type {geom.type!r}')
def _slope(a: t.Point, b: t.Point) -> float:
# slope between two points
dx = b[0] - a[0]
dy = b[1] - a[1]
if dx == 0:
dx = 0.01;
return math.atan(dy / dx)
def _rotate(p: t.Point, o: t.Point, a: float) -> t.Point:
# rotate P(oint) about O(rigin) by A(ngle)
return (
o[0] + (p[0] - o[0]) * math.cos(a) - (p[1] - o[1]) * math.sin(a),
o[1] + (p[0] - o[0]) * math.sin(a) + (p[1] - o[1]) * math.cos(a),
)