team-3/src/c3nav/mapdata/utils/svg.py
2017-10-17 12:41:22 +02:00

184 lines
7.6 KiB
Python

import io
import math
import re
import subprocess
import xml.etree.ElementTree as ET
from itertools import chain
from PIL import Image
from shapely.affinity import scale, translate
from shapely.ops import unary_union
class SVGImage:
def __init__(self, bounds, scale: float=1, buffer=0):
(self.bottom, self.left), (self.top, self.right) = bounds
self.width = self.right-self.left
self.height = self.top-self.bottom
self.scale = scale
self.buffer_px = int(math.ceil(buffer*self.scale))
self.g = ET.Element('g', {})
self.defs = ET.Element('defs')
self.def_i = 0
self.altitudes = {}
self.last_altitude = None
self.blurs = set()
def get_element(self, buffer=False):
width_px = self._trim_decimals(str(self.width*self.scale + (self.buffer_px*2 if buffer else 0)))
height_px = self._trim_decimals(str(self.height*self.scale + (self.buffer_px*2 if buffer else 0)))
offset_px = self._trim_decimals(str(-self.buffer_px)) if buffer else '0'
root = ET.Element('svg', {
'width': width_px,
'height': height_px,
'xmlns:svg': 'http://www.w3.org/2000/svg',
'xmlns': 'http://www.w3.org/2000/svg',
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
})
if buffer:
root.attrib['viewBox'] = ' '.join((offset_px, offset_px, width_px, height_px))
root.append(self.defs)
root.append(self.g)
return root
def get_xml(self, buffer=False):
return ET.tostring(self.get_element(buffer=buffer)).decode()
def get_png(self):
p = subprocess.run(('rsvg-convert', '--format', 'png'),
input=self.get_xml(buffer=True).encode(), stdout=subprocess.PIPE, check=True)
f = io.BytesIO(p.stdout)
img = Image.open(f)
img = img.crop((self.buffer_px, self.buffer_px,
self.buffer_px+int(self.width*self.scale),
self.buffer_px+int(self.height*self.scale)))
f = io.BytesIO()
img.save(f, 'PNG')
f.seek(0)
return f.read()
def new_defid(self):
defid = 's'+str(self.def_i)
self.def_i += 1
return defid
def _trim_decimals(self, data):
return re.sub(r'([0-9]+)\.0', r'\1', re.sub(r'([0-9]+\.[0-9])[0-9]+', r'\1', data))
def _create_geometry(self, geometry):
geometry = translate(geometry, xoff=0-self.left, yoff=0-self.bottom)
geometry = scale(geometry, xfact=1, yfact=-1, origin=(self.width / 2, self.height / 2))
geometry = scale(geometry, xfact=self.scale, yfact=self.scale, origin=(0, 0))
element = ET.fromstring(self._trim_decimals(geometry.svg(0, '#FFFFFF')))
if element.tag != 'g':
new_element = ET.Element('g')
new_element.append(element)
element = new_element
for elem in chain(element.findall('polyline'), element.findall('path')):
elem.attrib.pop('opacity', None)
elem.attrib.pop('fill', None)
elem.attrib.pop('fill-rule', None)
elem.attrib.pop('stroke', None)
elem.attrib.pop('stroke-width', None)
return element
def register_geometry(self, geometry, defid=None, as_clip_path=False, comment=None):
if defid is None:
defid = self.new_defid()
element = self._create_geometry(geometry)
if as_clip_path:
element.tag = 'clipPath'
element.set('id', defid)
self.defs.append(element)
return defid
def get_blur(self, elevation):
blur_id = 'blur'+str(elevation*100)
if elevation not in self.blurs:
blur_filter = ET.Element('filter', {'id': blur_id,
'width': '200%',
'height': '200%',
'x': '-50%',
'y': '-50%'})
blur_filter.append(ET.Element('feGaussianBlur',
{'in': 'SourceGraphic',
'stdDeviation': str(elevation*self.scale)}))
self.defs.append(blur_filter)
self.blurs.add(elevation)
return blur_id
def add_clip_path(self, *geometries, inverted=False, subtract=False, defid=None):
if defid is None:
defid = self.new_defid()
clippath = ET.Element('clipPath', {'id': defid})
clippath.append(ET.Element('use', {'xlink:href': '#' + geometries[0]}))
self.defs.append(clippath)
return defid
def clip_altitudes(self, new_geometry, new_altitude=None):
for altitude, geometry in tuple(self.altitudes.items()):
if altitude != new_altitude:
self.altitudes[altitude] = geometry.difference(new_geometry)
if self.altitudes[altitude].is_empty:
self.altitudes.pop(altitude)
if new_altitude is not None:
if self.last_altitude is not None and self.last_altitude > new_altitude:
raise ValueError('Altitudes have to be ascending.')
self.last_altitude = new_altitude
if new_altitude in self.altitudes:
self.altitudes[new_altitude] = unary_union([self.altitudes[new_altitude], new_geometry])
else:
self.altitudes[new_altitude] = new_geometry
def add_geometry(self, geometry=None, fill_color=None, fill_opacity=None, opacity=None, filter=None,
stroke_px=0.0, stroke_width=0.0, stroke_color=None, stroke_opacity=None, stroke_linejoin=None,
clip_path=None, altitude=None, elevation=None):
if geometry is not None:
if not geometry:
return
if isinstance(geometry, str):
element = ET.Element('use', {'xlink:href': '#'+geometry})
else:
element = self._create_geometry(geometry)
if altitude is not None or elevation is not None:
blur_radius = float(1 if elevation is None else elevation)
buffered_geometry = translate(geometry.buffer(blur_radius/20),
xoff=blur_radius/40, yoff=-blur_radius/40)
shadow_element = self._create_geometry(buffered_geometry)
shadow_element.set('fill', '#000000')
shadow_element.set('fill-opacity', '0.14')
shadow_element.set('filter', 'url(#'+self.get_blur(blur_radius/15)+')')
self.g.append(shadow_element)
self.clip_altitudes(geometry, altitude)
else:
element = ET.Element('rect', {'width': '100%', 'height': '100%'})
element.set('fill', fill_color or 'none')
if fill_opacity:
element.set('fill-opacity', str(fill_opacity)[:4])
if stroke_px:
element.set('stroke-width', self._trim_decimals(str(stroke_px)))
elif stroke_width:
element.set('stroke-width', self._trim_decimals(str(stroke_width * self.scale)))
if stroke_color:
element.set('stroke', stroke_color)
if stroke_opacity:
element.set('stroke-opacity', str(stroke_opacity)[:4])
if stroke_linejoin:
element.set('stroke-linejoin', stroke_linejoin)
if opacity:
element.set('opacity', str(opacity)[:4])
if filter:
element.set('filter', 'url(#'+filter+')')
if clip_path:
element.set('clip-path', 'url(#'+clip_path+')')
self.g.append(element)
return element