2017-10-17 12:24:38 +02:00
|
|
|
|
import io
|
|
|
|
|
import math
|
2017-05-13 14:59:03 +02:00
|
|
|
|
import re
|
2017-10-17 12:24:38 +02:00
|
|
|
|
import subprocess
|
2017-10-20 01:34:54 +02:00
|
|
|
|
from itertools import chain
|
2017-05-12 17:22:10 +02:00
|
|
|
|
|
2017-10-20 11:55:17 +02:00
|
|
|
|
import numpy as np
|
2017-10-17 13:03:35 +02:00
|
|
|
|
from django.conf import settings
|
2017-10-20 13:05:28 +02:00
|
|
|
|
from django.core import checks
|
2017-10-17 14:53:05 +02:00
|
|
|
|
from PIL import Image
|
2017-10-20 11:55:17 +02:00
|
|
|
|
from shapely.affinity import translate
|
2017-10-20 01:34:54 +02:00
|
|
|
|
from shapely.geometry import LineString, Polygon
|
2017-10-16 17:39:25 +02:00
|
|
|
|
from shapely.ops import unary_union
|
2017-05-12 17:22:10 +02:00
|
|
|
|
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# import gobject-inspect, cairo and rsvg if the native rsvg SVG_RENDERER should be used
|
2017-10-17 13:33:47 +02:00
|
|
|
|
if settings.SVG_RENDERER == 'rsvg':
|
|
|
|
|
import pgi
|
|
|
|
|
import cairocffi
|
|
|
|
|
pgi.require_version('Rsvg', '2.0')
|
|
|
|
|
from pgi.repository import Rsvg
|
|
|
|
|
|
2017-05-12 17:22:10 +02:00
|
|
|
|
|
2017-10-20 13:05:28 +02:00
|
|
|
|
@checks.register()
|
2017-10-17 13:03:35 +02:00
|
|
|
|
def check_svg_renderer(app_configs, **kwargs):
|
|
|
|
|
errors = []
|
2017-10-17 13:33:47 +02:00
|
|
|
|
if settings.SVG_RENDERER not in ('rsvg', 'rsvg-convert', 'inkscape'):
|
2017-10-17 13:03:35 +02:00
|
|
|
|
errors.append(
|
2017-10-20 13:05:28 +02:00
|
|
|
|
checks.Error(
|
2017-10-17 13:03:35 +02:00
|
|
|
|
'Invalid SVG renderer: '+settings.SVG_RENDERER,
|
|
|
|
|
obj='settings.SVG_RENDERER',
|
|
|
|
|
id='c3nav.mapdata.E001',
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return errors
|
|
|
|
|
|
|
|
|
|
|
2017-05-13 20:01:12 +02:00
|
|
|
|
class SVGImage:
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# draw an svg image. supports pseudo-3D shadow-rendering
|
2017-10-17 12:24:38 +02:00
|
|
|
|
def __init__(self, bounds, scale: float=1, buffer=0):
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# get image dimensions.
|
|
|
|
|
# note that these values describe the „viewport“ of the image, not its dimensions in pixels.
|
2017-10-29 11:32:44 +01:00
|
|
|
|
(self.left, self.bottom), (self.right, self.top) = bounds
|
2017-05-26 22:38:46 +02:00
|
|
|
|
self.width = self.right-self.left
|
|
|
|
|
self.height = self.top-self.bottom
|
2017-05-12 17:22:10 +02:00
|
|
|
|
self.scale = scale
|
2017-10-19 18:10:12 +02:00
|
|
|
|
|
|
|
|
|
# how many pixels around the image should be added and later cropped (otherwise rsvg does not blur correctly)
|
2017-10-17 12:24:38 +02:00
|
|
|
|
self.buffer_px = int(math.ceil(buffer*self.scale))
|
2017-10-19 18:10:12 +02:00
|
|
|
|
|
2017-10-19 23:50:21 +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
|
|
|
|
|
|
|
|
|
# keep track which area of the image has which altitude currently
|
2017-10-16 17:39:25 +02:00
|
|
|
|
self.altitudes = {}
|
|
|
|
|
self.last_altitude = None
|
2017-10-19 18:10:12 +02:00
|
|
|
|
|
2017-10-20 12:01:24 +02:00
|
|
|
|
# for fast numpy operations
|
|
|
|
|
self.np_scale = np.array((self.scale, -self.scale))
|
|
|
|
|
self.np_offset = np.array((-self.left*self.scale, self.top*self.scale))
|
|
|
|
|
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# keep track of created blur filters to avoid duplicates
|
2017-10-17 12:24:38 +02:00
|
|
|
|
self.blurs = set()
|
2017-05-12 23:37:03 +02:00
|
|
|
|
|
2017-10-29 09:32:15 +01:00
|
|
|
|
self._create_geometry_cache = {}
|
|
|
|
|
|
2017-10-17 13:33:47 +02:00
|
|
|
|
def get_dimensions_px(self, buffer):
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# get dimensions of the image in pixels, with or without buffer
|
2017-10-17 13:33:47 +02:00
|
|
|
|
width_px = self.width * self.scale + (self.buffer_px * 2 if buffer else 0)
|
|
|
|
|
height_px = self.height * self.scale + (self.buffer_px * 2 if buffer else 0)
|
|
|
|
|
return height_px, width_px
|
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
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
|
2017-10-17 13:33:47 +02:00
|
|
|
|
height_px, width_px = (self._trim_decimals(str(i)) for i in self.get_dimensions_px(buffer))
|
2017-10-17 12:41:22 +02:00
|
|
|
|
offset_px = self._trim_decimals(str(-self.buffer_px)) if buffer else '0'
|
2017-05-12 17:22:10 +02:00
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
attribs = ' viewBox="'+' '.join((offset_px, offset_px, width_px, height_px))+'"' if buffer else ''
|
|
|
|
|
|
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+'>')
|
2017-10-19 23:50:21 +02:00
|
|
|
|
if self.defs:
|
|
|
|
|
result += '<defs>'+self.defs+'</defs>'
|
|
|
|
|
if self.g:
|
|
|
|
|
result += '<g>'+self.g+'</g>'
|
|
|
|
|
result += '</svg>'
|
|
|
|
|
return result
|
2017-05-13 20:01:12 +02:00
|
|
|
|
|
2017-10-20 22:37:54 +02:00
|
|
|
|
empty_tile = (b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\x01\x03\x00\x00\x00f\xbc:%\x00'
|
|
|
|
|
b'\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\x1fIDATh\xde'
|
|
|
|
|
b'\xed\xc1\x01\r\x00\x00\x00\xc2\xa0\xf7Om\x0e7\xa0\x00\x00\x00\x00\x00\x00\x00\x00\xbe\r!\x00\x00'
|
|
|
|
|
b'\x01\x7f\x19\x9c\xa7\x00\x00\x00\x00IEND\xaeB`\x82')
|
|
|
|
|
|
2017-10-17 14:34:09 +02:00
|
|
|
|
def get_png(self, f=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()
|
2017-10-20 22:37:54 +02:00
|
|
|
|
|
|
|
|
|
if self.get_dimensions_px(buffer=False) == (256, 256) and not self.g:
|
|
|
|
|
return self.empty_tile
|
|
|
|
|
|
2017-10-29 13:36:34 +01:00
|
|
|
|
background_color = '#DCDCDC'
|
|
|
|
|
|
2017-10-17 13:03:35 +02:00
|
|
|
|
if settings.SVG_RENDERER == 'rsvg':
|
2017-10-17 14:34:09 +02:00
|
|
|
|
# create buffered surfaces
|
|
|
|
|
buffered_surface = cairocffi.SVGSurface(None, *(int(i) for i in self.get_dimensions_px(buffer=True)))
|
|
|
|
|
buffered_context = cairocffi.Context(buffered_surface)
|
2017-10-17 13:33:47 +02:00
|
|
|
|
|
2017-10-17 14:34:09 +02:00
|
|
|
|
# draw svg with rsvg
|
2017-10-17 13:33:47 +02:00
|
|
|
|
handle = Rsvg.Handle()
|
|
|
|
|
svg = handle.new_from_data(self.get_xml(buffer=True).encode())
|
2017-10-17 14:34:09 +02:00
|
|
|
|
svg.render_cairo(buffered_context)
|
|
|
|
|
|
2017-10-29 13:36:34 +01:00
|
|
|
|
# create cropped image
|
|
|
|
|
surface = buffered_surface.create_similar(cairocffi.CONTENT_COLOR,
|
2017-10-17 14:34:09 +02:00
|
|
|
|
*(int(i) for i in self.get_dimensions_px(buffer=False)))
|
|
|
|
|
context = cairocffi.Context(surface)
|
2017-10-29 13:36:34 +01:00
|
|
|
|
|
|
|
|
|
# set background color
|
|
|
|
|
context.set_source(cairocffi.SolidPattern(*(int(background_color[i:i+2], 16)/255 for i in range(1, 6, 2))))
|
|
|
|
|
context.paint()
|
|
|
|
|
|
|
|
|
|
# paste buffered immage with offset
|
2017-10-17 14:34:09 +02:00
|
|
|
|
context.set_source_surface(buffered_surface, -self.buffer_px, -self.buffer_px)
|
|
|
|
|
context.paint()
|
|
|
|
|
if f is None:
|
|
|
|
|
return surface.write_to_png()
|
2017-10-17 15:36:15 +02:00
|
|
|
|
f.write(surface.write_to_png())
|
2017-10-17 14:34:09 +02:00
|
|
|
|
|
2017-10-17 13:33:47 +02:00
|
|
|
|
elif settings.SVG_RENDERER == 'rsvg-convert':
|
2017-10-29 13:36:34 +01:00
|
|
|
|
p = subprocess.run(('rsvg-convert', '-b', background_color, '--format', 'png'),
|
2017-10-17 13:03:35 +02:00
|
|
|
|
input=self.get_xml(buffer=True).encode(), stdout=subprocess.PIPE, check=True)
|
2017-10-17 14:34:09 +02:00
|
|
|
|
png = io.BytesIO(p.stdout)
|
|
|
|
|
img = Image.open(png)
|
|
|
|
|
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)))
|
|
|
|
|
if f is None:
|
|
|
|
|
f = io.BytesIO()
|
|
|
|
|
img.save(f, 'PNG')
|
|
|
|
|
f.seek(0)
|
|
|
|
|
return f.read()
|
|
|
|
|
img.save(f, 'PNG')
|
|
|
|
|
|
2017-10-17 13:03:35 +02:00
|
|
|
|
elif settings.SVG_RENDERER == 'inkscape':
|
2017-10-29 13:36:34 +01:00
|
|
|
|
p = subprocess.run(('inkscape', '-z', '-b', background_color, '-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'):]
|
2017-10-17 14:34:09 +02:00
|
|
|
|
if f is None:
|
|
|
|
|
return png
|
|
|
|
|
f.write(png)
|
2017-10-17 12:24:38 +02:00
|
|
|
|
|
2017-05-13 20:53:44 +02:00
|
|
|
|
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)
|
2017-05-13 20:53:44 +02:00
|
|
|
|
|
2017-10-20 01:34:54 +02:00
|
|
|
|
def _geometry_to_svg(self, geom):
|
2017-10-20 12:12:45 +02:00
|
|
|
|
# scale and move geometry and create svg code for it
|
2017-10-20 01:34:54 +02:00
|
|
|
|
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') %
|
2017-10-20 12:01:24 +02:00
|
|
|
|
tuple((np.array(ring)*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 ', ' ')
|
2017-10-20 01:34:54 +02:00
|
|
|
|
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))+'"/>') %
|
2017-10-20 12:01:24 +02:00
|
|
|
|
tuple((np.array(geom)*self.np_scale+self.np_offset).flatten())).replace('.0 ', ' ')
|
2017-10-20 01:34:54 +02:00
|
|
|
|
try:
|
|
|
|
|
geoms = geom.geoms
|
|
|
|
|
except AttributeError:
|
|
|
|
|
return ''
|
|
|
|
|
return ''.join(self._geometry_to_svg(g) for g in geoms)
|
|
|
|
|
|
2017-10-29 09:47:53 +01:00
|
|
|
|
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
|
2017-10-29 09:47:53 +01:00
|
|
|
|
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)
|
2017-10-29 09:47:53 +01:00
|
|
|
|
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
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
def register_clip_path(self, geometry):
|
2017-10-20 01:05:35 +02:00
|
|
|
|
defid = 'clip'+str(self.clip_path_i)
|
2017-10-20 01:34:54 +02:00
|
|
|
|
self.defs += self._create_geometry(geometry, ' id="'+defid+'"', tag='clipPath')
|
2017-10-19 23:50:21 +02:00
|
|
|
|
self.clip_path_i += 1
|
2017-05-12 23:37:03 +02:00
|
|
|
|
return defid
|
|
|
|
|
|
2017-10-19 12:08:18 +02:00
|
|
|
|
def add_shadow(self, geometry, elevation, 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
|
2017-10-18 16:28:38 +02:00
|
|
|
|
elevation = 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))
|
2017-10-17 12:24:38 +02:00
|
|
|
|
if elevation not in self.blurs:
|
2017-10-19 23:50:21 +02:00
|
|
|
|
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)+'"/>'
|
2017-10-19 23:50:21 +02:00
|
|
|
|
'</filter>')
|
2017-10-17 12:24:38 +02:00
|
|
|
|
self.blurs.add(elevation)
|
2017-10-18 16:28:38 +02:00
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
attribs = ' filter="url(#'+blur_id+')" fill="#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)+')"'
|
2017-10-19 23:50:21 +02:00
|
|
|
|
shadow = self._create_geometry(shadow_geom, attribs)
|
|
|
|
|
self.g += shadow
|
2017-10-17 12:24:38 +02:00
|
|
|
|
|
2017-10-16 17:39:25 +02:00
|
|
|
|
def clip_altitudes(self, new_geometry, new_altitude=None):
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# registrer new geometry with specific (or no) altitude
|
|
|
|
|
# a geometry with no altitude will reset the altitude information of its area as if nothing was ever there
|
2017-10-17 12:24:38 +02:00
|
|
|
|
for altitude, geometry in tuple(self.altitudes.items()):
|
2017-10-16 17:39:25 +02:00
|
|
|
|
if altitude != new_altitude:
|
|
|
|
|
self.altitudes[altitude] = geometry.difference(new_geometry)
|
2017-10-17 12:24:38 +02:00
|
|
|
|
if self.altitudes[altitude].is_empty:
|
|
|
|
|
self.altitudes.pop(altitude)
|
2017-10-16 17:39:25 +02:00
|
|
|
|
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
|
|
|
|
|
|
2017-05-13 20:12:42 +02:00
|
|
|
|
def add_geometry(self, geometry=None, fill_color=None, fill_opacity=None, opacity=None, filter=None,
|
2017-10-16 17:10:32 +02:00
|
|
|
|
stroke_px=0.0, stroke_width=0.0, stroke_color=None, stroke_opacity=None, stroke_linejoin=None,
|
2017-10-29 09:47:53 +01:00
|
|
|
|
clip_path=None, altitude=None, elevation=None, shape_cache_key=None):
|
2017-10-19 18:10:12 +02:00
|
|
|
|
# draw a shapely geometry with a given style
|
|
|
|
|
# if altitude is set, the geometry will get a calculated shadow relative to the other geometries
|
|
|
|
|
# if elevation is set, the geometry will get a shadow with exactly this elevation
|
2017-10-19 23:50:21 +02:00
|
|
|
|
|
2017-10-20 00:51:30 +02:00
|
|
|
|
# if fill_color is set, filter out geometries that cannot be filled
|
|
|
|
|
if fill_color is not None:
|
|
|
|
|
try:
|
|
|
|
|
geometry.geoms
|
|
|
|
|
except AttributeError:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
geometry = type(geometry)(tuple(geom for geom in geometry.geoms if hasattr(geom, 'exterior')))
|
|
|
|
|
if geometry.is_empty:
|
|
|
|
|
pass
|
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
attribs = ' fill="'+(fill_color or 'none')+'"'
|
|
|
|
|
if fill_opacity:
|
|
|
|
|
attribs += ' fill-opacity="'+str(fill_opacity)[:4]+'"'
|
2017-10-29 09:32:15 +01:00
|
|
|
|
if stroke_width:
|
|
|
|
|
width = stroke_width*self.scale
|
|
|
|
|
if stroke_px:
|
|
|
|
|
width = max(width, stroke_px)
|
|
|
|
|
attribs += ' stroke-width="' + self._trim_decimals(str(width)) + '"'
|
|
|
|
|
elif stroke_px:
|
2017-10-19 23:50:21 +02:00
|
|
|
|
attribs += ' stroke-width="'+self._trim_decimals(str(stroke_px))+'"'
|
|
|
|
|
if stroke_color:
|
|
|
|
|
attribs += ' stroke="'+stroke_color+'"'
|
|
|
|
|
if stroke_opacity:
|
|
|
|
|
attribs += ' stroke-opacity="'+str(stroke_opacity)[:4]+'"'
|
|
|
|
|
if stroke_linejoin:
|
|
|
|
|
attribs += ' stroke-linejoin="'+stroke_linejoin+'"'
|
|
|
|
|
if opacity:
|
|
|
|
|
attribs += ' opacity="'+str(opacity)[:4]+'"'
|
|
|
|
|
if filter:
|
|
|
|
|
attribs += ' filter="url(#'+filter+')"'
|
|
|
|
|
if clip_path:
|
|
|
|
|
attribs += ' clip-path="url(#'+clip_path+')"'
|
|
|
|
|
|
2017-05-13 20:33:02 +02:00
|
|
|
|
if geometry is not None:
|
2017-05-13 20:48:27 +02:00
|
|
|
|
if not geometry:
|
|
|
|
|
return
|
2017-10-16 17:39:25 +02:00
|
|
|
|
|
|
|
|
|
if altitude is not None or elevation is not None:
|
2017-10-19 11:36:53 +02:00
|
|
|
|
if elevation is not None:
|
|
|
|
|
if elevation:
|
2017-10-19 12:08:18 +02:00
|
|
|
|
self.add_shadow(geometry, elevation)
|
2017-10-19 11:36:53 +02:00
|
|
|
|
else:
|
|
|
|
|
for other_altitude, other_geom in self.altitudes.items():
|
2017-10-19 12:08:18 +02:00
|
|
|
|
self.add_shadow(geometry, altitude-other_altitude, clip_path=other_geom)
|
2017-10-17 12:24:38 +02:00
|
|
|
|
|
2017-10-16 17:39:25 +02:00
|
|
|
|
self.clip_altitudes(geometry, altitude)
|
|
|
|
|
|
2017-10-29 09:47:53 +01: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:
|
2017-10-19 23:50:21 +02:00
|
|
|
|
element = '<rect width="100%" height="100%"'+attribs+'>'
|
2017-05-12 17:22:10 +02:00
|
|
|
|
|
2017-10-19 23:50:21 +02:00
|
|
|
|
self.g += element
|
2017-05-12 17:22:10 +02:00
|
|
|
|
return element
|