team-3/src/c3nav/mapdata/render/engines/svg.py

287 lines
12 KiB
Python
Raw Normal View History

import io
import re
import subprocess
2017-10-29 14:24:45 +01:00
import zlib
from itertools import chain
from typing import Optional
2017-05-12 17:22:10 +02:00
import numpy as np
from django.conf import settings
from django.core import checks
from shapely.affinity import translate
from shapely.geometry import LineString, Polygon
2017-10-19 18:10:12 +02:00
# import gobject-inspect, cairo and rsvg if the native rsvg SVG_RENDERER should be used
from shapely.ops import unary_union
2017-11-05 12:32:07 +01:00
from c3nav.mapdata.render.engines.base import FillAttribs, RenderEngine, StrokeAttribs
2023-12-26 18:15:07 +01:00
from c3nav.mapdata.utils.geometry import unwrap_geom
if settings.SVG_RENDERER == 'rsvg':
try:
import pgi
pgi.require_version('Rsvg', '2.0')
from pgi.repository import Rsvg
import cairocffi as cairo
except ImportError:
import gi
gi.require_version('Rsvg', '2.0')
import cairo
from gi.repository import Rsvg
2024-02-07 19:59:53 +01:00
elif settings.SVG_RENDERER == 'rsvg-convert':
from PIL import Image
2017-05-12 17:22:10 +02:00
2023-12-26 18:15:07 +01:00
def unwrap_hybrid_geom(geom):
from c3nav.mapdata.render.geometry import HybridGeometry
if isinstance(geom, HybridGeometry):
geom = geom.geom
return unwrap_geom(geom)
@checks.register()
def check_svg_renderer(app_configs, **kwargs):
errors = []
if settings.SVG_RENDERER not in ('rsvg', 'rsvg-convert', 'inkscape'):
errors.append(
checks.Error(
'Invalid SVG renderer: '+settings.SVG_RENDERER,
obj='settings.SVG_RENDERER',
2017-11-06 11:18:45 +01:00
id='c3nav.mapdata.E002',
)
)
return errors
class SVGEngine(RenderEngine):
filetype = 'png'
2017-10-19 18:10:12 +02:00
# draw an svg image. supports pseudo-3D shadow-rendering
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
2017-10-19 18:10:12 +02:00
# create base elements and counter for clip path ids
self.g = ''
self.defs = ''
self.clip_path_i = 0
2017-10-19 18:10:12 +02:00
# for fast numpy operations
self.np_scale = np.array((self.scale, -self.scale))
self.np_offset = np.array((-self.minx * self.scale, self.maxy * self.scale))
2017-10-19 18:10:12 +02:00
# keep track of created blur filters to avoid duplicates
self.blurs = set()
2017-05-12 23:37:03 +02:00
# keep track which area of the image has which altitude currently
self.altitudes = {}
self.last_altitude = None
2017-10-29 09:32:15 +01:00
self._create_geometry_cache = {}
def get_xml(self, buffer=False):
2017-10-19 18:10:12 +02:00
# get the root <svg> element as an ElementTree element, with or without buffer
if buffer:
width_px = self._trim_decimals(str(self.buffered_width))
height_px = self._trim_decimals(str(self.buffered_height))
offset_px = self._trim_decimals(str(-self.buffer))
attribs = ' viewBox="' + ' '.join((offset_px, offset_px, width_px, height_px)) + '"' if buffer else ''
else:
width_px = self._trim_decimals(str(self.width))
height_px = self._trim_decimals(str(self.height))
attribs = ''
2017-10-20 01:00:27 +02:00
result = ('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
'width="'+width_px+'" height="'+height_px+'"'+attribs+'>')
if self.defs:
result += '<defs>'+self.defs+'</defs>'
if self.g:
result += '<g>'+self.g+'</g>'
result += '</svg>'
return result
2017-11-14 20:53:04 +01:00
def render(self, filename=None):
2017-10-19 18:10:12 +02:00
# render the image to png. returns bytes if f is None, otherwise it calls f.write()
if self.width == 256 and self.height == 256 and not self.g:
2017-10-29 14:24:45 +01:00
# create empty tile png with minimal size, indexed color palette with only one entry
plte = b'PLTE' + bytearray(tuple(int(i*255) for i in self.background_rgb))
2017-10-29 14:24:45 +01:00
return (b'\x89PNG\r\n\x1a\n' +
b'\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\x01\x03\x00\x00\x00f\xbc:%\x00\x00\x00\x03' +
plte + zlib.crc32(plte).to_bytes(4, byteorder='big') +
b'\x00\x00\x00\x1fIDATh\xde\xed\xc1\x01\r\x00\x00\x00\xc2\xa0\xf7Om\x0e7\xa0\x00\x00\x00\x00\x00' +
b'\x00\x00\x00\xbe\r!\x00\x00\x01\x7f\x19\x9c\xa7\x00\x00\x00\x00IEND\xaeB`\x82')
if settings.SVG_RENDERER == 'rsvg':
# create buffered surfaces
buffered_surface = cairo.SVGSurface(None, self.buffered_width, self.buffered_height)
buffered_context = cairo.Context(buffered_surface)
# draw svg with rsvg
handle = Rsvg.Handle()
svg = handle.new_from_data(self.get_xml(buffer=True).encode())
svg.render_cairo(buffered_context)
# create cropped image
surface = buffered_surface.create_similar(cairo.CONTENT_COLOR_ALPHA, self.width, self.height)
context = cairo.Context(surface)
# set background color
context.set_source(cairo.SolidPattern(*self.background_rgb))
context.paint()
# paste buffered immage with offset
context.set_source_surface(buffered_surface, -self.buffer, -self.buffer)
context.paint()
f = io.BytesIO()
surface.write_to_png(f)
f.seek(0)
return f.read()
elif settings.SVG_RENDERER == 'rsvg-convert':
p = subprocess.run(('rsvg-convert', '-b', self.background, '--format', 'png'),
input=self.get_xml(buffer=True).encode(), stdout=subprocess.PIPE, check=True)
png = io.BytesIO(p.stdout)
img = Image.open(png)
img = img.crop((self.buffer, self.buffer,
self.buffer + self.width,
self.buffer + self.height))
f = io.BytesIO()
img.save(f, 'PNG')
f.seek(0)
return f.read()
elif settings.SVG_RENDERER == 'inkscape':
p = subprocess.run(('inkscape', '-z', '-b', self.background, '-e', '/dev/stderr', '/dev/stdin'),
input=self.get_xml().encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True)
2017-10-17 14:53:05 +02:00
png = p.stderr[p.stderr.index(b'\x89PNG'):]
return png
def _trim_decimals(self, data):
2017-10-20 00:58:19 +02:00
# remove trailing zeros from a decimal yes this is slow, but it greatly speeds up cairo rendering
2017-10-29 09:32:15 +01:00
return re.sub(r'([0-9]+)((\.[1-9])[0-9]+|\.[0-9]+)?', r'\1\2', data)
def _geometry_to_svg(self, geom):
2017-10-20 12:12:45 +02:00
# scale and move geometry and create svg code for it
if isinstance(geom, Polygon):
return ('<path d="' +
2017-10-20 12:07:09 +02:00
' '.join((('M %.1f %.1f L'+(' %.1f %.1f'*(len(ring.coords)-1))+' z') %
2022-04-03 17:34:31 +02:00
tuple((np.array(ring.coords)*self.np_scale+self.np_offset).flatten()))
2017-10-20 11:31:22 +02:00
for ring in chain((geom.exterior,), geom.interiors))
2017-10-20 01:59:53 +02:00
+ '"/>').replace('.0 ', ' ')
if isinstance(geom, LineString):
2017-10-20 17:03:56 +02:00
return (('<path d="M %.1f %.1f L'+(' %.1f %.1f'*(len(geom.coords)-1))+'"/>') %
2022-04-03 17:34:31 +02:00
tuple((np.array(geom.coords)*self.np_scale+self.np_offset).flatten())).replace('.0 ', ' ')
try:
geoms = geom.geoms
except AttributeError:
return ''
return ''.join(self._geometry_to_svg(g) for g in geoms)
def _create_geometry(self, geometry, attribs='', tag='g', cache_key=None):
2017-10-19 18:10:12 +02:00
# convert a shapely geometry into an svg xml element
result = None
if cache_key is not None:
result = self._create_geometry_cache.get(cache_key, None)
2017-10-29 09:32:15 +01:00
if result is None:
result = self._geometry_to_svg(geometry)
if cache_key is not None:
self._create_geometry_cache[cache_key] = result
2017-10-29 09:32:15 +01:00
return '<'+tag+attribs+'>'+result+'</'+tag+'>'
2017-05-13 20:10:12 +02:00
def register_clip_path(self, geometry):
2017-10-20 01:05:35 +02:00
defid = 'clip'+str(self.clip_path_i)
self.defs += self._create_geometry(geometry, ' id="'+defid+'"', tag='clipPath')
self.clip_path_i += 1
2017-05-12 23:37:03 +02:00
return defid
def add_shadow(self, geometry, elevation, color, clip_path=None):
2017-10-19 18:10:12 +02:00
# add a shadow for the given geometry with the given elevation and, optionally, a clip path
elevation = float(min(elevation, 2))
2017-10-19 12:08:18 +02:00
blur_radius = elevation / 3 * 0.25
shadow_geom = translate(geometry.buffer(blur_radius),
xoff=(elevation / 3 * 0.12), yoff=-(elevation / 3 * 0.12))
if clip_path is not None:
if shadow_geom.distance(clip_path) >= blur_radius:
return
2017-10-18 16:28:38 +02:00
blur_id = 'blur'+str(int(elevation*100))
if elevation not in self.blurs:
self.defs += ('<filter id="'+blur_id+'" width="200%" height="200%" x="-50%" y="-50%">'
2017-10-20 16:00:24 +02:00
'<feGaussianBlur stdDeviation="'+str(blur_radius * self.scale)+'"/>'
'</filter>')
self.blurs.add(elevation)
2017-10-18 16:28:38 +02:00
attribs = ' filter="url(#'+blur_id+')" fill="'+(color or '#000')+'" fill-opacity="0.2"'
2017-10-19 12:08:18 +02:00
if clip_path:
2017-10-20 01:04:03 +02:00
attribs += ' clip-path="url(#'+self.register_clip_path(clip_path)+')"'
shadow = self._create_geometry(shadow_geom, attribs)
self.g += shadow
def clip_altitudes(self, new_geometry, new_altitude=None):
# register new geometry with an altitude
# a geometry with no altitude will reset the altitude information of its area as if nothing was ever there
if self.last_altitude is not None and self.last_altitude > new_altitude:
raise ValueError('Altitudes have to be ascending.')
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
2024-12-19 00:05:37 +01:00
def darken(self, area, much=False):
2017-12-20 12:21:44 +01:00
if area:
2024-12-19 00:05:37 +01:00
self.add_geometry(geometry=area, fill=FillAttribs('#000000', 0.5 if much else 0.1), category='darken')
2017-12-20 12:21:44 +01:00
def _add_geometry(self, geometry, fill: Optional[FillAttribs], stroke: Optional[StrokeAttribs],
altitude=None, height=None, shadow_color=None, shape_cache_key=None, **kwargs):
2023-12-26 18:15:07 +01:00
geometry = self.buffered_bbox.intersection(unwrap_hybrid_geom(geometry))
if geometry.is_empty:
return
if fill:
attribs = ' fill="'+(fill.color)+'"'
if fill.opacity:
attribs += ' fill-opacity="'+str(fill.opacity)[:4]+'"'
else:
attribs = ' fill="none"'
if altitude is not None and stroke is None:
2017-11-25 15:22:45 +01:00
stroke = StrokeAttribs('rgba(0, 0, 0, 0.15)', 0.05, min_px=0.2)
if stroke:
width = stroke.width*self.scale
if stroke.min_px:
width = max(width, stroke.min_px)
attribs += ' stroke-width="' + self._trim_decimals(str(width)) + '" stroke="' + stroke.color + '"'
if stroke.opacity:
attribs += ' stroke-opacity="'+str(stroke.opacity)[:4]+'"'
if geometry is not None:
2017-10-16 17:39:25 +02:00
if False:
# old shadow rendering. currently needs too much resources
2017-11-05 13:24:35 +01:00
if altitude is not None or height is not None:
if height is not None:
if height:
self.add_shadow(geometry, height, '#000')
else:
for other_altitude, other_geom in self.altitudes.items():
self.add_shadow(geometry, altitude-other_altitude, clip_path=other_geom)
self.clip_altitudes(geometry, altitude, '#000')
else:
2017-11-05 13:24:35 +01:00
if height is not None:
self.add_shadow(geometry, height, shadow_color)
2017-10-16 17:39:25 +02:00
element = self._create_geometry(geometry, attribs, cache_key=shape_cache_key)
2017-10-18 16:28:38 +02:00
2017-05-13 19:19:28 +02:00
else:
element = '<rect width="100%" height="100%"'+attribs+'>'
2017-05-12 17:22:10 +02:00
self.g += element