Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets

Implement text on a path SVG element (#22)

Add the path parameter to the Text constructor, update example 1

Marek afd0de67 0b1d0a92

Changed files
+150 -55
drawSvg
examples
+10 -7
README.md
···
stroke='black'))
# Draw a rectangle
-
r = draw.Rectangle(0,0,40,50, fill='#1248ff')
r.appendTitle("Our first rectangle") # Add a tooltip
d.append(r)
···
fill='red', stroke_width=2, stroke='black'))
# Draw an arbitrary path (a triangle in this case)
-
p = draw.Path(stroke_width=2, stroke='green',
-
fill='black', fill_opacity=0.5)
-
p.M(-30,5) # Start path at point (-30, 5)
-
p.l(60,30) # Draw line to (60, 30)
-
p.h(-70) # Draw horizontal line to x=-70
-
p.Z() # Draw line to start
d.append(p)
# Draw multiple circular arcs
d.append(draw.ArcLine(60,-20,20,60,270,
···
stroke='black'))
# Draw a rectangle
+
r = draw.Rectangle(-80,0,40,50, fill='#1248ff')
r.appendTitle("Our first rectangle") # Add a tooltip
d.append(r)
···
fill='red', stroke_width=2, stroke='black'))
# Draw an arbitrary path (a triangle in this case)
+
p = draw.Path(stroke_width=2, stroke='lime',
+
fill='black', fill_opacity=0.2)
+
p.M(-10, 20) # Start path at point (-10, 20)
+
p.C(30, -10, 30, 50, 70, 20) # Draw a curve to (70, 20)
d.append(p)
+
+
# Draw text
+
d.append(draw.Text('Basic text', 8, -10, 35, fill='blue')) # Text with font size 8
+
d.append(draw.Text('Path text', 8, path=p, text_anchor='start', valign='middle'))
+
d.append(draw.Text(['Multi-line', 'text'], 8, path=p, text_anchor='end'))
# Draw multiple circular arcs
d.append(draw.ArcLine(60,-20,20,60,270,
+122 -43
drawSvg/elements.py
···
''' Text
Additional keyword arguments are output as additional arguments to the
-
SVG node e.g. fill="red", font_size=20, text_anchor="middle". '''
TAG_NAME = 'text'
hasContent = True
-
def __init__(self, text, fontSize, x, y, center=False, valign=None,
-
lineHeight=1, **kwargs):
-
singleLine = isinstance(text, str)
-
if '\n' in text:
-
text = text.splitlines()
-
singleLine = False
-
if not singleLine:
-
text = tuple(text)
-
numLines = len(text)
else:
-
numLines = 1
-
centerOffset = 0
-
emOffset = 0
-
if center:
-
if 'text_anchor' not in kwargs:
-
kwargs['text_anchor'] = 'middle'
-
if valign is None:
-
if singleLine:
-
# Backwards compatible centering
-
centerOffset = fontSize*0.5*center
-
else:
-
emOffset = 0.4 - lineHeight * (numLines - 1) / 2
if valign == 'middle':
-
emOffset = 0.4 - lineHeight * (numLines - 1) / 2
elif valign == 'top':
-
emOffset = 1
elif valign == 'bottom':
-
emOffset = -lineHeight * (numLines - 1)
-
if centerOffset:
-
try:
-
fontSize = float(fontSize)
-
except TypeError:
-
pass
-
else:
-
translate = 'translate(0,{})'.format(centerOffset)
-
if 'transform' in kwargs:
-
kwargs['transform'] += ' ' + translate
-
else:
-
kwargs['transform'] = translate
-
super().__init__(x=x, y=-y, font_size=fontSize, **kwargs)
if singleLine:
-
self.escapedText = xml.escape(text)
else:
self.escapedText = ''
-
# Text is an iterable
-
for i, line in enumerate(text):
-
dy = '{}em'.format(emOffset if i == 0 else lineHeight)
-
self.appendLine(line, x=x, dy=dy)
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
if dryRun:
return
···
for child in children:
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
def appendLine(self, line, **kwargs):
self.append(TSpan(line, **kwargs))
class _TextContainingElement(DrawingBasicElement):
''' A private parent class used for elements that only have plain text
content. '''
···
if dryRun:
return
outputFile.write(self.escapedText)
-
class TSpan(_TextContainingElement):
''' A line of text within the Text element. '''
···
''' Text
Additional keyword arguments are output as additional arguments to the
+
SVG node e.g. fill='red', font_size=20, text_anchor='middle',
+
letter_spacing=1.5.
+
+
CairoSVG bug with letter spacing text on a path: The first two letters
+
are always spaced as if letter_spacing=1. '''
TAG_NAME = 'text'
hasContent = True
+
def __new__(cls, text, *args, path=None, id=None, _skipCheck=False,
+
**kwargs):
+
# Check for the special case of multi-line text on a path
+
# This is inconsistently implemented by renderers so we return a group
+
# of single-line text on paths instead.
+
if path is not None and not _skipCheck:
+
text, _ = cls._handleTextArgument(text, True)
+
if len(text) > 1:
+
# Special case
+
g = Group(id=id)
+
for i, line in enumerate(text):
+
subtext = [None] * len(text)
+
subtext[i] = line
+
g.append(Text(subtext, *args, path=path, _skipCheck=True,
+
**kwargs))
+
return g
+
return super().__new__(cls)
+
def __init__(self, text, fontSize, x=None, y=None, *, center=False,
+
valign=None, lineHeight=1, lineOffset=0, path=None,
+
startOffset=None, pathArgs=None, tspanArgs=None,
+
cairoFix=True, _skipCheck=False, **kwargs):
+
# Check argument requirements
+
if path is None:
+
if x is None or y is None:
+
raise TypeError(
+
"__init__() missing required arguments: 'x' and 'y' "
+
"are required unless 'path' is specified")
+
try:
+
y = -y
+
except TypeError:
+
pass
else:
+
if x is not None or y is not None:
+
raise TypeError(
+
"__init__() conflicting arguments: 'x' and 'y' "
+
"should not be used when 'path' is specified")
+
if pathArgs is None:
+
pathArgs = {}
+
if startOffset is not None:
+
pathArgs.setdefault('startOffset', startOffset)
+
if tspanArgs is None:
+
tspanArgs = {}
+
onPath = path is not None
+
+
text, singleLine = self._handleTextArgument(text, forceMulti=onPath)
+
numLines = len(text)
+
+
# Text alignment
+
centerCompat = False
+
if center and valign is None:
+
valign = 'middle'
+
centerCompat = singleLine and not onPath
+
if center and kwargs.get('text_anchor') is None:
+
kwargs['text_anchor'] = 'middle'
if valign == 'middle':
+
if centerCompat: # Backwards compatible centering
+
lineOffset += 0.5 * center
+
else:
+
lineOffset += 0.4 - lineHeight * (numLines - 1) / 2
elif valign == 'top':
+
lineOffset += 1
elif valign == 'bottom':
+
lineOffset += -lineHeight * (numLines - 1)
+
if singleLine:
+
dy = '{}em'.format(lineOffset)
+
kwargs.setdefault('dy', dy)
+
# Text alignment on a path
+
if onPath:
+
if kwargs.get('text_anchor') == 'start':
+
pathArgs.setdefault('startOffset', '0')
+
elif kwargs.get('text_anchor') == 'middle':
+
pathArgs.setdefault('startOffset', '50%')
+
elif kwargs.get('text_anchor') == 'end':
+
if cairoFix and 'startOffset' not in pathArgs:
+
# Fix CairoSVG not drawing the last character with aligned
+
# right
+
tspanArgs.setdefault('dx', -1)
+
pathArgs.setdefault('startOffset', '100%')
+
+
super().__init__(x=x, y=y, font_size=fontSize, **kwargs)
+
self._textPath = None
if singleLine:
+
self.escapedText = xml.escape(text[0])
else:
+
# Add elements for each line of text
self.escapedText = ''
+
if path is None:
+
# Text is an iterable
+
for i, line in enumerate(text):
+
dy = '{}em'.format(lineOffset if i == 0 else lineHeight)
+
self.appendLine(line, x=x, dy=dy, **tspanArgs)
+
else:
+
self._textPath = _TextPath(path, **pathArgs)
+
assert sum(bool(line) for line in text) <= 1, (
+
'Logic error, __new__ should handle multi-line paths')
+
for i, line in enumerate(text):
+
if not line: continue
+
dy = '{}em'.format(lineOffset + i*lineHeight)
+
tspan = TSpan(line, dy=dy, **tspanArgs)
+
self._textPath.append(tspan)
+
self.append(self._textPath)
+
@staticmethod
+
def _handleTextArgument(text, forceMulti=False):
+
# Handle multi-line text (contains '\n' or is a list of strings)
+
singleLine = isinstance(text, str)
+
if isinstance(text, str):
+
singleLine = '\n' not in text and not forceMulti
+
if singleLine:
+
text = (text,)
+
else:
+
text = tuple(text.splitlines())
+
singleLine = False
+
else:
+
singleLine = False
+
text = tuple(text)
+
return text, singleLine
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
if dryRun:
return
···
for child in children:
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
def appendLine(self, line, **kwargs):
+
if self._textPath is not None:
+
raise ValueError('appendLine is not supported for text on a path')
self.append(TSpan(line, **kwargs))
+
class _TextPath(DrawingParentElement):
+
TAG_NAME = 'textPath'
+
hasContent = True
+
def __init__(self, path, **kwargs):
+
super().__init__(xlink__href=path, **kwargs)
+
class _TextContainingElement(DrawingBasicElement):
''' A private parent class used for elements that only have plain text
content. '''
···
if dryRun:
return
outputFile.write(self.escapedText)
class TSpan(_TextContainingElement):
''' A line of text within the Text element. '''
+18 -5
examples/example1.svg
···
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="400" height="200" viewBox="-100.0 -50.0 200 100">
<defs>
-
<marker markerWidth="4.0" markerHeight="4.0" viewBox="-0.1 -0.5 1.0 1.0" orient="auto" id="d0">
<path d="M-0.1,0.5 L-0.1,-0.5 L0.9,0 Z" fill="red" />
</marker>
</defs>
<path d="M-80,45 L70,49 L95,-49 L-90,-40" fill="#eeee00" stroke="black" />
-
<rect x="0" y="-50" width="40" height="50" fill="#1248ff">
<title>Our first rectangle</title>
</rect>
<circle cx="-40" cy="10" r="30" fill="red" stroke-width="2" stroke="black" />
-
<path d="M-30,-5 l60,-30 h-70 Z" stroke-width="2" stroke="green" fill="black" fill-opacity="0.5" />
<circle cx="60" cy="20" r="20" stroke-dasharray="73.30382858376184 52.35987755982988" stroke-dashoffset="-31.41592653589793" stroke="red" stroke-width="5" fill="red" fill-opacity="0.2" />
<path d="M70.0,2.679491924311229 A20,20,0,1,0,59.99999999999999,40.0" stroke="green" stroke-width="3" fill="none" />
<path d="M59.99999999999999,40.0 A20,20,0,1,1,70.0,2.679491924311229" stroke="blue" stroke-width="1" fill="black" fill-opacity="0.3" />
-
<path d="M20,40 L20,27 L0,20" stroke="red" stroke-width="2" fill="none" marker-end="url(#d0)" />
-
<path d="M30,20 L0,10" stroke="red" stroke-width="2" fill="none" marker-end="url(#d0)" />
</svg>
···
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="400" height="200" viewBox="-100.0 -50.0 200 100">
<defs>
+
<path d="M-10,-20 C30,10,30,-50,70,-20" stroke-width="2" stroke="lime" fill="black" fill-opacity="0.2" id="d0" />
+
<marker markerWidth="4.0" markerHeight="4.0" viewBox="-0.1 -0.5 1.0 1.0" orient="auto" id="d1">
<path d="M-0.1,0.5 L-0.1,-0.5 L0.9,0 Z" fill="red" />
</marker>
</defs>
<path d="M-80,45 L70,49 L95,-49 L-90,-40" fill="#eeee00" stroke="black" />
+
<rect x="-80" y="-50" width="40" height="50" fill="#1248ff">
<title>Our first rectangle</title>
</rect>
<circle cx="-40" cy="10" r="30" fill="red" stroke-width="2" stroke="black" />
+
<use xlink:href="#d0" />
+
<text x="-10" y="-35" font-size="8" fill="blue" dy="0em">Basic text</text>
+
<text font-size="8" text-anchor="start"><textPath xlink:href="#d0" startOffset="0">
+
<tspan dy="0.4em">Path text</tspan>
+
</textPath></text>
+
<g>
+
<text font-size="8" text-anchor="end"><textPath xlink:href="#d0" startOffset="100%">
+
<tspan dy="0em" dx="-1">Multi-line</tspan>
+
</textPath></text>
+
<text font-size="8" text-anchor="end"><textPath xlink:href="#d0" startOffset="100%">
+
<tspan dy="1em" dx="-1">text</tspan>
+
</textPath></text>
+
</g>
<circle cx="60" cy="20" r="20" stroke-dasharray="73.30382858376184 52.35987755982988" stroke-dashoffset="-31.41592653589793" stroke="red" stroke-width="5" fill="red" fill-opacity="0.2" />
<path d="M70.0,2.679491924311229 A20,20,0,1,0,59.99999999999999,40.0" stroke="green" stroke-width="3" fill="none" />
<path d="M59.99999999999999,40.0 A20,20,0,1,1,70.0,2.679491924311229" stroke="blue" stroke-width="1" fill="black" fill-opacity="0.3" />
+
<path d="M20,40 L20,27 L0,20" stroke="red" stroke-width="2" fill="none" marker-end="url(#d1)" />
+
<path d="M30,20 L0,10" stroke="red" stroke-width="2" fill="none" marker-end="url(#d1)" />
</svg>