Railroad Diagrams¶
The code in this notebook helps with drawing syntax-diagrams. It is a (slightly customized) copy of the excellent library from Tab Atkins jr., which unfortunately is not available as a Python package.
Prerequisites
This notebook needs some understanding on advanced concepts in Python and Graphics, notably
* classes
* the Python `with` statement
* Scalable Vector Graphics
Railroad diagrams implementation
import bookutils.setup
class C:
# Display constants
DEBUG = False # if true, writes some debug information into attributes
VS = 8 # minimum vertical separation between things. For a 3px stroke, must be at least 4
AR = 10 # radius of arcs
DIAGRAM_CLASS = 'railroad-diagram' # class to put on the root <svg>
# is the stroke width an odd (1px, 3px, etc) pixel length?
STROKE_ODD_PIXEL_LENGTH = True
# how to align items when they have extra space. left/right/center
INTERNAL_ALIGNMENT = 'center'
# width of each monospace character. play until you find the right value
# for your font
CHAR_WIDTH = 8.5
COMMENT_CHAR_WIDTH = 7 # comments are in smaller text by default
DEFAULT_STYLE = '''\
svg.railroad-diagram {
}
svg.railroad-diagram path {
stroke-width:3;
stroke:black;
fill:white;
}
svg.railroad-diagram text {
font:14px "Fira Mono", monospace;
text-anchor:middle;
}
svg.railroad-diagram text.label{
text-anchor:start;
}
svg.railroad-diagram text.comment{
font:italic 12px "Fira Mono", monospace;
}
svg.railroad-diagram rect{
stroke-width:2;
stroke:black;
fill:mistyrose;
}
'''
def e(text):
text = re.sub(r"&", '&', str(text))
text = re.sub(r"<", '<', str(text))
text = re.sub(r">", '>', str(text))
return str(text)
def determineGaps(outer, inner):
diff = outer - inner
if C.INTERNAL_ALIGNMENT == 'left':
return 0, diff
elif C.INTERNAL_ALIGNMENT == 'right':
return diff, 0
else:
return diff / 2, diff / 2
def doubleenumerate(seq):
length = len(list(seq))
for i, item in enumerate(seq):
yield i, i - length, item
def addDebug(el):
if not C.DEBUG:
return
el.attrs['data-x'] = "{0} w:{1} h:{2}/{3}/{4}".format(
type(el).__name__, el.width, el.up, el.height, el.down)
class DiagramItem:
def __init__(self, name, attrs=None, text=None):
self.name = name
# up = distance it projects above the entry line
# height = distance between the entry/exit lines
# down = distance it projects below the exit line
self.height = 0
self.attrs = attrs or {}
self.children = [text] if text else []
self.needsSpace = False
def format(self, x, y, width):
raise NotImplementedError # Virtual
def addTo(self, parent):
parent.children.append(self)
return self
def writeSvg(self, write):
write(u'<{0}'.format(self.name))
for name, value in sorted(self.attrs.items()):
write(u' {0}="{1}"'.format(name, e(value)))
write(u'>')
if self.name in ["g", "svg"]:
write(u'\n')
for child in self.children:
if isinstance(child, DiagramItem):
child.writeSvg(write)
else:
write(e(child))
write(u'</{0}>'.format(self.name))
def __eq__(self, other):
return isinstance(self, type(
other)) and self.__dict__ == other.__dict__
def __ne__(self, other):
return not (self == other)
class Path(DiagramItem):
def __init__(self, x, y):
self.x = x
self.y = y
DiagramItem.__init__(self, 'path', {'d': 'M%s %s' % (x, y)})
def m(self, x, y):
self.attrs['d'] += 'm{0} {1}'.format(x, y)
return self
def ll(self, x, y): # was l(), which violates PEP8 -- AZ
self.attrs['d'] += 'l{0} {1}'.format(x, y)
return self
def h(self, val):
self.attrs['d'] += 'h{0}'.format(val)
return self
def right(self, val):
return self.h(max(0, val))
def left(self, val):
return self.h(-max(0, val))
def v(self, val):
self.attrs['d'] += 'v{0}'.format(val)
return self
def down(self, val):
return self.v(max(0, val))
def up(self, val):
return self.v(-max(0, val))
def arc_8(self, start, dir):
# 1/8 of a circle
arc = C.AR
s2 = 1 / math.sqrt(2) * arc
s2inv = (arc - s2)
path = "a {0} {0} 0 0 {1} ".format(arc, "1" if dir == 'cw' else "0")
sd = start + dir
if sd == 'ncw':
offset = [s2, s2inv]
elif sd == 'necw':
offset = [s2inv, s2]
elif sd == 'ecw':
offset = [-s2inv, s2]
elif sd == 'secw':
offset = [-s2, s2inv]
elif sd == 'scw':
offset = [-s2, -s2inv]
elif sd == 'swcw':
offset = [-s2inv, -s2]
elif sd == 'wcw':
offset = [s2inv, -s2]
elif sd == 'nwcw':
offset = [s2, -s2inv]
elif sd == 'nccw':
offset = [-s2, s2inv]
elif sd == 'nwccw':
offset = [-s2inv, s2]
elif sd == 'wccw':
offset = [s2inv, s2]
elif sd == 'swccw':
offset = [s2, s2inv]
elif sd == 'sccw':
offset = [s2, -s2inv]
elif sd == 'seccw':
offset = [s2inv, -s2]
elif sd == 'eccw':
offset = [-s2inv, -s2]
elif sd == 'neccw':
offset = [-s2, -s2inv]
path += " ".join(str(x) for x in offset)
self.attrs['d'] += path
return self
def arc(self, sweep):
x = C.AR
y = C.AR
if sweep[0] == 'e' or sweep[1] == 'w':
x *= -1
if sweep[0] == 's' or sweep[1] == 'n':
y *= -1
cw = 1 if sweep == 'ne' or sweep == 'es' or sweep == 'sw' or sweep == 'wn' else 0
self.attrs['d'] += 'a{0} {0} 0 0 {1} {2} {3}'.format(C.AR, cw, x, y)
return self
def format(self):
self.attrs['d'] += 'h.5'
return self
def __repr__(self):
return 'Path(%r, %r)' % (self.x, self.y)
def wrapString(value):
return value if isinstance(value, DiagramItem) else Terminal(value)
class Style(DiagramItem):
def __init__(self, css):
self.name = 'style'
self.css = css
self.height = 0
self.width = 0
self.needsSpace = False
def __repr__(self):
return 'Style(%r)' % css
def format(self, x, y, width):
return self
def writeSvg(self, write):
# Write included stylesheet as CDATA. See
# https:#developer.mozilla.org/en-US/docs/Web/SVG/Element/style
cdata = u'/* <![CDATA[ */\n{css}\n/* ]]> */\n'.format(css=self.css)
write(u'<style>{cdata}</style>'.format(cdata=cdata))
class Diagram(DiagramItem):
def __init__(self, *items, **kwargs):
# Accepts a type=[simple|complex] kwarg
DiagramItem.__init__(
self, 'svg', {'class': C.DIAGRAM_CLASS, 'xmlns': "http://www.w3.org/2000/svg"})
self.type = kwargs.get("type", "simple")
self.items = [wrapString(item) for item in items]
if items and not isinstance(items[0], Start):
self.items.insert(0, Start(self.type))
if items and not isinstance(items[-1], End):
self.items.append(End(self.type))
self.css = kwargs.get("css", C.DEFAULT_STYLE)
if self.css:
self.items.insert(0, Style(self.css))
self.up = 0
self.down = 0
self.height = 0
self.width = 0
for item in self.items:
if isinstance(item, Style):
continue
self.width += item.width + (20 if item.needsSpace else 0)
self.up = max(self.up, item.up - self.height)
self.height += item.height
self.down = max(self.down - item.height, item.down)
if self.items[0].needsSpace:
self.width -= 10
if self.items[-1].needsSpace:
self.width -= 10
self.formatted = False
def __repr__(self):
if self.css:
items = ', '.join(map(repr, self.items[2:-1]))
else:
items = ', '.join(map(repr, self.items[1:-1]))
pieces = [] if not items else [items]
if self.css != C.DEFAULT_STYLE:
pieces.append('css=%r' % self.css)
if self.type != 'simple':
pieces.append('type=%r' % self.type)
return 'Diagram(%s)' % ', '.join(pieces)
def format(self, paddingTop=20, paddingRight=None,
paddingBottom=None, paddingLeft=None):
if paddingRight is None:
paddingRight = paddingTop
if paddingBottom is None:
paddingBottom = paddingTop
if paddingLeft is None:
paddingLeft = paddingRight
x = paddingLeft
y = paddingTop + self.up
g = DiagramItem('g')
if C.STROKE_ODD_PIXEL_LENGTH:
g.attrs['transform'] = 'translate(.5 .5)'
for item in self.items:
if item.needsSpace:
Path(x, y).h(10).addTo(g)
x += 10
item.format(x, y, item.width).addTo(g)
x += item.width
y += item.height
if item.needsSpace:
Path(x, y).h(10).addTo(g)
x += 10
self.attrs['width'] = self.width + paddingLeft + paddingRight
self.attrs['height'] = self.up + self.height + \
self.down + paddingTop + paddingBottom
self.attrs['viewBox'] = "0 0 {width} {height}".format(**self.attrs)
g.addTo(self)
self.formatted = True
return self
def writeSvg(self, write):
if not self.formatted:
self.format()
return DiagramItem.writeSvg(self, write)
def parseCSSGrammar(self, text):
token_patterns = {
'keyword': r"[\w-]+\(?",
'type': r"<[\w-]+(\(\))?>",
'char': r"[/,()]",
'literal': r"'(.)'",
'openbracket': r"\[",
'closebracket': r"\]",
'closebracketbang': r"\]!",
'bar': r"\|",
'doublebar': r"\|\|",
'doubleand': r"&&",
'multstar': r"\*",
'multplus': r"\+",
'multhash': r"#",
'multnum1': r"{\s*(\d+)\s*}",
'multnum2': r"{\s*(\d+)\s*,\s*(\d*)\s*}",
'multhashnum1': r"#{\s*(\d+)\s*}",
'multhashnum2': r"{\s*(\d+)\s*,\s*(\d*)\s*}"
}
class Sequence(DiagramItem):
def __init__(self, *items):
DiagramItem.__init__(self, 'g')
self.items = [wrapString(item) for item in items]
self.needsSpace = True
self.up = 0
self.down = 0
self.height = 0
self.width = 0
for item in self.items:
self.width += item.width + (20 if item.needsSpace else 0)
self.up = max(self.up, item.up - self.height)
self.height += item.height
self.down = max(self.down - item.height, item.down)
if self.items[0].needsSpace:
self.width -= 10
if self.items[-1].needsSpace:
self.width -= 10
addDebug(self)
def __repr__(self):
items = ', '.join(map(repr, self.items))
return 'Sequence(%s)' % items
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y + self.height).h(rightGap).addTo(self)
x += leftGap
for i, item in enumerate(self.items):
if item.needsSpace and i > 0:
Path(x, y).h(10).addTo(self)
x += 10
item.format(x, y, item.width).addTo(self)
x += item.width
y += item.height
if item.needsSpace and i < len(self.items) - 1:
Path(x, y).h(10).addTo(self)
x += 10
return self
class Stack(DiagramItem):
def __init__(self, *items):
DiagramItem.__init__(self, 'g')
self.items = [wrapString(item) for item in items]
self.needsSpace = True
self.width = max(item.width + (20 if item.needsSpace else 0)
for item in self.items)
# pretty sure that space calc is totes wrong
if len(self.items) > 1:
self.width += C.AR * 2
self.up = self.items[0].up
self.down = self.items[-1].down
self.height = 0
last = len(self.items) - 1
for i, item in enumerate(self.items):
self.height += item.height
if i > 0:
self.height += max(C.AR * 2, item.up + C.VS)
if i < last:
self.height += max(C.AR * 2, item.down + C.VS)
addDebug(self)
def __repr__(self):
items = ', '.join(repr(item) for item in self.items)
return 'Stack(%s)' % items
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
Path(x, y).h(leftGap).addTo(self)
x += leftGap
xInitial = x
if len(self.items) > 1:
Path(x, y).h(C.AR).addTo(self)
x += C.AR
innerWidth = self.width - C.AR * 2
else:
innerWidth = self.width
for i, item in enumerate(self.items):
item.format(x, y, innerWidth).addTo(self)
x += innerWidth
y += item.height
if i != len(self.items) - 1:
(Path(x, y)
.arc('ne').down(max(0, item.down + C.VS - C.AR * 2))
.arc('es').left(innerWidth)
.arc('nw').down(max(0, self.items[i + 1].up + C.VS - C.AR * 2))
.arc('ws').addTo(self))
y += max(item.down + C.VS, C.AR * 2) + \
max(self.items[i + 1].up + C.VS, C.AR * 2)
x = xInitial + C.AR
if len(self.items) > 1:
Path(x, y).h(C.AR).addTo(self)
x += C.AR
Path(x, y).h(rightGap).addTo(self)
return self
class OptionalSequence(DiagramItem):
def __new__(cls, *items):
if len(items) <= 1:
return Sequence(*items)
else:
return super(OptionalSequence, cls).__new__(cls)
def __init__(self, *items):
DiagramItem.__init__(self, 'g')
self.items = [wrapString(item) for item in items]
self.needsSpace = False
self.width = 0
self.up = 0
self.height = sum(item.height for item in self.items)
self.down = self.items[0].down
heightSoFar = 0
for i, item in enumerate(self.items):
self.up = max(self.up, max(C.AR * 2, item.up + C.VS) - heightSoFar)
heightSoFar += item.height
if i > 0:
self.down = max(self.height + self.down, heightSoFar
+ max(C.AR * 2, item.down + C.VS)) - self.height
itemWidth = item.width + (20 if item.needsSpace else 0)
if i == 0:
self.width += C.AR + max(itemWidth, C.AR)
else:
self.width += C.AR * 2 + max(itemWidth, C.AR) + C.AR
addDebug(self)
def __repr__(self):
items = ', '.join(repr(item) for item in self.items)
return 'OptionalSequence(%s)' % items
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
Path(x, y).right(leftGap).addTo(self)
Path(x + leftGap + self.width, y
+ self.height).right(rightGap).addTo(self)
x += leftGap
upperLineY = y - self.up
last = len(self.items) - 1
for i, item in enumerate(self.items):
itemSpace = 10 if item.needsSpace else 0
itemWidth = item.width + itemSpace
if i == 0:
# Upper skip
(Path(x, y)
.arc('se')
.up(y - upperLineY - C.AR * 2)
.arc('wn')
.right(itemWidth - C.AR)
.arc('ne')
.down(y + item.height - upperLineY - C.AR * 2)
.arc('ws')
.addTo(self))
# Straight line
(Path(x, y)
.right(itemSpace + C.AR)
.addTo(self))
item.format(x + itemSpace + C.AR, y, item.width).addTo(self)
x += itemWidth + C.AR
y += item.height
elif i < last:
# Upper skip
(Path(x, upperLineY)
.right(C.AR * 2 + max(itemWidth, C.AR) + C.AR)
.arc('ne')
.down(y - upperLineY + item.height - C.AR * 2)
.arc('ws')
.addTo(self))
# Straight line
(Path(x, y)
.right(C.AR * 2)
.addTo(self))
item.format(x + C.AR * 2, y, item.width).addTo(self)
(Path(x + item.width + C.AR * 2, y + item.height)
.right(itemSpace + C.AR)
.addTo(self))
# Lower skip
(Path(x, y)
.arc('ne')
.down(item.height + max(item.down + C.VS, C.AR * 2) - C.AR * 2)
.arc('ws')
.right(itemWidth - C.AR)
.arc('se')
.up(item.down + C.VS - C.AR * 2)
.arc('wn')
.addTo(self))
x += C.AR * 2 + max(itemWidth, C.AR) + C.AR
y += item.height
else:
# Straight line
(Path(x, y)
.right(C.AR * 2)
.addTo(self))
item.format(x + C.AR * 2, y, item.width).addTo(self)
(Path(x + C.AR * 2 + item.width, y + item.height)
.right(itemSpace + C.AR)
.addTo(self))
# Lower skip
(Path(x, y)
.arc('ne')
.down(item.height + max(item.down + C.VS, C.AR * 2) - C.AR * 2)
.arc('ws')
.right(itemWidth - C.AR)
.arc('se')
.up(item.down + C.VS - C.AR * 2)
.arc('wn')
.addTo(self))
return self
class AlternatingSequence(DiagramItem):
def __new__(cls, *items):
if len(items) == 2:
return super(AlternatingSequence, cls).__new__(cls)
else:
raise Exception(
"AlternatingSequence takes exactly two arguments got " + len(items))
def __init__(self, *items):
DiagramItem.__init__(self, 'g')
self.items = [wrapString(item) for item in items]
self.needsSpace = False
arc = C.AR
vert = C.VS
first = self.items[0]
second = self.items[1]
arcX = 1 / math.sqrt(2) * arc * 2
arcY = (1 - 1 / math.sqrt(2)) * arc * 2
crossY = max(arc, vert)
crossX = (crossY - arcY) + arcX
firstOut = max(arc + arc, crossY / 2 + arc + arc,
crossY / 2 + vert + first.down)
self.up = firstOut + first.height + first.up
secondIn = max(arc + arc, crossY / 2 + arc + arc,
crossY / 2 + vert + second.up)
self.down = secondIn + second.height + second.down
self.height = 0
firstWidth = (20 if first.needsSpace else 0) + first.width
secondWidth = (20 if second.needsSpace else 0) + second.width
self.width = 2 * arc + max(firstWidth, crossX, secondWidth) + 2 * arc
addDebug(self)
def __repr__(self):
items = ', '.join(repr(item) for item in self.items)
return 'AlternatingSequence(%s)' % items
def format(self, x, y, width):
arc = C.AR
gaps = determineGaps(width, self.width)
Path(x, y).right(gaps[0]).addTo(self)
x += gaps[0]
Path(x + self.width, y).right(gaps[1]).addTo(self)
# bounding box
# Path(x+gaps[0], y).up(self.up).right(self.width).down(self.up+self.down).left(self.width).up(self.down).addTo(self)
first = self.items[0]
second = self.items[1]
# top
firstIn = self.up - first.up
firstOut = self.up - first.up - first.height
Path(x, y).arc('se').up(firstIn - 2 * arc).arc('wn').addTo(self)
first.format(
x
+ 2
* arc,
y
- firstIn,
self.width
- 4
* arc).addTo(self)
Path(x + self.width - 2 * arc, y
- firstOut).arc('ne').down(firstOut - 2 * arc).arc('ws').addTo(self)
# bottom
secondIn = self.down - second.down - second.height
secondOut = self.down - second.down
Path(x, y).arc('ne').down(secondIn - 2 * arc).arc('ws').addTo(self)
second.format(
x
+ 2
* arc,
y
+ secondIn,
self.width
- 4
* arc).addTo(self)
Path(x + self.width - 2 * arc, y
+ secondOut).arc('se').up(secondOut - 2 * arc).arc('wn').addTo(self)
# crossover
arcX = 1 / Math.sqrt(2) * arc * 2
arcY = (1 - 1 / Math.sqrt(2)) * arc * 2
crossY = max(arc, C.VS)
crossX = (crossY - arcY) + arcX
crossBar = (self.width - 4 * arc - crossX) / 2
(Path(x + arc, y - crossY / 2 - arc).arc('ws').right(crossBar)
.arc_8('n', 'cw').ll(crossX - arcX, crossY - arcY).arc_8('sw', 'ccw')
.right(crossBar).arc('ne').addTo(self))
(Path(x + arc, y + crossY / 2 + arc).arc('wn').right(crossBar)
.arc_8('s', 'ccw').ll(crossX - arcX, -(crossY - arcY)).arc_8('nw', 'cw')
.right(crossBar).arc('se').addTo(self))
return self
class Choice(DiagramItem):
def __init__(self, default, *items):
DiagramItem.__init__(self, 'g')
assert default < len(items)
self.default = default
self.items = [wrapString(item) for item in items]
self.width = C.AR * 4 + max(item.width for item in self.items)
self.up = self.items[0].up
self.down = self.items[-1].down
self.height = self.items[default].height
for i, item in enumerate(self.items):
if i in [default - 1, default + 1]:
arcs = C.AR * 2
else:
arcs = C.AR
if i < default:
self.up += max(arcs, item.height + item.down
+ C.VS + self.items[i + 1].up)
elif i == default:
continue
else:
self.down += max(arcs, item.up + C.VS
+ self.items[i - 1].down + self.items[i - 1].height)
# already counted in self.height
self.down -= self.items[default].height
addDebug(self)
def __repr__(self):
items = ', '.join(repr(item) for item in self.items)
return 'Choice(%r, %s)' % (self.default, items)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y + self.height).h(rightGap).addTo(self)
x += leftGap
innerWidth = self.width - C.AR * 4
default = self.items[self.default]
# Do the elements that curve above
above = self.items[:self.default][::-1]
if above:
distanceFromY = max(
C.AR * 2,
default.up
+ C.VS
+ above[0].down
+ above[0].height)
for i, ni, item in doubleenumerate(above):
Path(x, y).arc('se').up(distanceFromY
- C.AR * 2).arc('wn').addTo(self)
item.format(x + C.AR * 2, y - distanceFromY,
innerWidth).addTo(self)
Path(x + C.AR * 2 + innerWidth, y - distanceFromY + item.height).arc('ne') \
.down(distanceFromY - item.height + default.height - C.AR * 2).arc('ws').addTo(self)
if ni < -1:
distanceFromY += max(
C.AR,
item.up
+ C.VS
+ above[i + 1].down
+ above[i + 1].height)
# Do the straight-line path.
Path(x, y).right(C.AR * 2).addTo(self)
self.items[self.default].format(
x + C.AR * 2, y, innerWidth).addTo(self)
Path(x + C.AR * 2 + innerWidth, y
+ self.height).right(C.AR * 2).addTo(self)
# Do the elements that curve below
below = self.items[self.default + 1:]
if below:
distanceFromY = max(
C.AR * 2,
default.height
+ default.down
+ C.VS
+ below[0].up)
for i, item in enumerate(below):
Path(x, y).arc('ne').down(
distanceFromY - C.AR * 2).arc('ws').addTo(self)
item.format(x + C.AR * 2, y + distanceFromY,
innerWidth).addTo(self)
Path(x + C.AR * 2 + innerWidth, y + distanceFromY + item.height).arc('se') \
.up(distanceFromY - C.AR * 2 + item.height - default.height).arc('wn').addTo(self)
distanceFromY += max(
C.AR,
item.height
+ item.down
+ C.VS
+ (below[i + 1].up if i + 1 < len(below) else 0))
return self
class MultipleChoice(DiagramItem):
def __init__(self, default, type, *items):
DiagramItem.__init__(self, 'g')
assert 0 <= default < len(items)
assert type in ["any", "all"]
self.default = default
self.type = type
self.needsSpace = True
self.items = [wrapString(item) for item in items]
self.innerWidth = max(item.width for item in self.items)
self.width = 30 + C.AR + self.innerWidth + C.AR + 20
self.up = self.items[0].up
self.down = self.items[-1].down
self.height = self.items[default].height
for i, item in enumerate(self.items):
if i in [default - 1, default + 1]:
minimum = 10 + C.AR
else:
minimum = C.AR
if i < default:
self.up += max(minimum, item.height
+ item.down + C.VS + self.items[i + 1].up)
elif i == default:
continue
else:
self.down += max(minimum, item.up + C.VS
+ self.items[i - 1].down + self.items[i - 1].height)
# already counted in self.height
self.down -= self.items[default].height
addDebug(self)
def __repr__(self):
items = ', '.join(map(repr, self.items))
return 'MultipleChoice(%r, %r, %s)' % (self.default, self.type, items)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y + self.height).h(rightGap).addTo(self)
x += leftGap
default = self.items[self.default]
# Do the elements that curve above
above = self.items[:self.default][::-1]
if above:
distanceFromY = max(
10 + C.AR,
default.up
+ C.VS
+ above[0].down
+ above[0].height)
for i, ni, item in doubleenumerate(above):
(Path(x + 30, y)
.up(distanceFromY - C.AR)
.arc('wn')
.addTo(self))
item.format(x + 30 + C.AR, y - distanceFromY,
self.innerWidth).addTo(self)
(Path(x + 30 + C.AR + self.innerWidth, y - distanceFromY + item.height)
.arc('ne')
.down(distanceFromY - item.height + default.height - C.AR - 10)
.addTo(self))
if ni < -1:
distanceFromY += max(
C.AR,
item.up
+ C.VS
+ above[i + 1].down
+ above[i + 1].height)
# Do the straight-line path.
Path(x + 30, y).right(C.AR).addTo(self)
self.items[self.default].format(
x + 30 + C.AR, y, self.innerWidth).addTo(self)
Path(x + 30 + C.AR + self.innerWidth, y
+ self.height).right(C.AR).addTo(self)
# Do the elements that curve below
below = self.items[self.default + 1:]
if below:
distanceFromY = max(
10 + C.AR,
default.height
+ default.down
+ C.VS
+ below[0].up)
for i, item in enumerate(below):
(Path(x + 30, y)
.down(distanceFromY - C.AR)
.arc('ws')
.addTo(self))
item.format(x + 30 + C.AR, y + distanceFromY,
self.innerWidth).addTo(self)
(Path(x + 30 + C.AR + self.innerWidth, y + distanceFromY + item.height)
.arc('se')
.up(distanceFromY - C.AR + item.height - default.height - 10)
.addTo(self))
distanceFromY += max(
C.AR,
item.height
+ item.down
+ C.VS
+ (below[i + 1].up if i + 1 < len(below) else 0))
text = DiagramItem('g', attrs={"class": "diagram-text"}).addTo(self)
DiagramItem('title', text="take one or more branches, once each, in any order" if self.type
== "any" else "take all branches, once each, in any order").addTo(text)
DiagramItem('path', attrs={
"d": "M {x} {y} h -26 a 4 4 0 0 0 -4 4 v 12 a 4 4 0 0 0 4 4 h 26 z".format(x=x + 30, y=y - 10),
"class": "diagram-text"
}).addTo(text)
DiagramItem('text', text="1+" if self.type == "any" else "all", attrs={
"x": x + 15,
"y": y + 4,
"class": "diagram-text"
}).addTo(text)
DiagramItem('path', attrs={
"d": "M {x} {y} h 16 a 4 4 0 0 1 4 4 v 12 a 4 4 0 0 1 -4 4 h -16 z".format(x=x + self.width - 20, y=y - 10),
"class": "diagram-text"
}).addTo(text)
DiagramItem('text', text=u"↺", attrs={
"x": x + self.width - 10,
"y": y + 4,
"class": "diagram-arrow"
}).addTo(text)
return self
class HorizontalChoice(DiagramItem):
def __new__(cls, *items):
if len(items) <= 1:
return Sequence(*items)
else:
return super(HorizontalChoice, cls).__new__(cls)
def __init__(self, *items):
DiagramItem.__init__(self, 'g')
self.items = [wrapString(item) for item in items]
allButLast = self.items[:-1]
middles = self.items[1:-1]
first = self.items[0]
last = self.items[-1]
self.needsSpace = False
self.width = (C.AR # starting track
+ C.AR * 2 * (len(self.items) - 1) # inbetween tracks
+ sum(x.width + (20 if x.needsSpace else 0)
for x in self.items) # items
# needs space to curve up
+ (C.AR if last.height > 0 else 0)
+ C.AR) # ending track
# Always exits at entrance height
self.height = 0
# All but the last have a track running above them
self._upperTrack = max(
C.AR * 2,
C.VS,
max(x.up for x in allButLast) + C.VS
)
self.up = max(self._upperTrack, last.up)
# All but the first have a track running below them
# Last either straight-lines or curves up, so has different calculation
self._lowerTrack = max(
C.VS,
max(x.height + max(x.down + C.VS, C.AR * 2)
for x in middles) if middles else 0,
last.height + last.down + C.VS
)
if first.height < self._lowerTrack:
# Make sure there's at least 2*C.AR room between first exit and
# lower track
self._lowerTrack = max(self._lowerTrack, first.height + C.AR * 2)
self.down = max(self._lowerTrack, first.height + first.down)
addDebug(self)
def format(self, x, y, width):
# Hook up the two sides if self is narrower than its stated width.
leftGap, rightGap = determineGaps(width, self.width)
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y + self.height).h(rightGap).addTo(self)
x += leftGap
first = self.items[0]
last = self.items[-1]
# upper track
upperSpan = (sum(x.width + (20 if x.needsSpace else 0) for x in self.items[:-1])
+ (len(self.items) - 2) * C.AR * 2
- C.AR)
(Path(x, y)
.arc('se')
.up(self._upperTrack - C.AR * 2)
.arc('wn')
.h(upperSpan)
.addTo(self))
# lower track
lowerSpan = (sum(x.width + (20 if x.needsSpace else 0) for x in self.items[1:])
+ (len(self.items) - 2) * C.AR * 2
+ (C.AR if last.height > 0 else 0)
- C.AR)
lowerStart = x + C.AR + first.width + \
(20 if first.needsSpace else 0) + C.AR * 2
(Path(lowerStart, y + self._lowerTrack)
.h(lowerSpan)
.arc('se')
.up(self._lowerTrack - C.AR * 2)
.arc('wn')
.addTo(self))
# Items
for [i, item] in enumerate(self.items):
# input track
if i == 0:
(Path(x, y)
.h(C.AR)
.addTo(self))
x += C.AR
else:
(Path(x, y - self._upperTrack)
.arc('ne')
.v(self._upperTrack - C.AR * 2)
.arc('ws')
.addTo(self))
x += C.AR * 2
# item
itemWidth = item.width + (20 if item.needsSpace else 0)
item.format(x, y, itemWidth).addTo(self)
x += itemWidth
# output track
if i == len(self.items) - 1:
if item.height == 0:
(Path(x, y)
.h(C.AR)
.addTo(self))
else:
(Path(x, y + item.height)
.arc('se')
.addTo(self))
elif i == 0 and item.height > self._lowerTrack:
# Needs to arc up to meet the lower track, not down.
if item.height - self._lowerTrack >= C.AR * 2:
(Path(x, y + item.height)
.arc('se')
.v(self._lowerTrack - item.height + C.AR * 2)
.arc('wn')
.addTo(self))
else:
# Not enough space to fit two arcs
# so just bail and draw a straight line for now.
(Path(x, y + item.height)
.ll(C.AR * 2, self._lowerTrack - item.height)
.addTo(self))
else:
(Path(x, y + item.height)
.arc('ne')
.v(self._lowerTrack - item.height - C.AR * 2)
.arc('ws')
.addTo(self))
return self
def Optional(item, skip=False):
return Choice(0 if skip else 1, Skip(), item)
class OneOrMore(DiagramItem):
def __init__(self, item, repeat=None):
DiagramItem.__init__(self, 'g')
repeat = repeat or Skip()
self.item = wrapString(item)
self.rep = wrapString(repeat)
self.width = max(self.item.width, self.rep.width) + C.AR * 2
self.height = self.item.height
self.up = self.item.up
self.down = max(
C.AR * 2,
self.item.down + C.VS + self.rep.up + self.rep.height + self.rep.down)
self.needsSpace = True
addDebug(self)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y + self.height).h(rightGap).addTo(self)
x += leftGap
# Draw item
Path(x, y).right(C.AR).addTo(self)
self.item.format(x + C.AR, y, self.width - C.AR * 2).addTo(self)
Path(x + self.width - C.AR, y + self.height).right(C.AR).addTo(self)
# Draw repeat arc
distanceFromY = max(C.AR * 2, self.item.height
+ self.item.down + C.VS + self.rep.up)
Path(x + C.AR, y).arc('nw').down(distanceFromY - C.AR * 2) \
.arc('ws').addTo(self)
self.rep.format(x + C.AR, y + distanceFromY,
self.width - C.AR * 2).addTo(self)
Path(x + self.width - C.AR, y + distanceFromY + self.rep.height).arc('se') \
.up(distanceFromY - C.AR * 2 + self.rep.height - self.item.height).arc('en').addTo(self)
return self
def __repr__(self):
return 'OneOrMore(%r, repeat=%r)' % (self.item, self.rep)
def ZeroOrMore(item, repeat=None, skip=False):
result = Optional(OneOrMore(item, repeat), skip)
return result
class Start(DiagramItem):
def __init__(self, type="simple", label=None):
DiagramItem.__init__(self, 'g')
if label:
self.width = max(20, len(label) * C.CHAR_WIDTH + 10)
else:
self.width = 20
self.up = 10
self.down = 10
self.type = type
self.label = label
addDebug(self)
def format(self, x, y, _width):
path = Path(x, y - 10)
if self.type == "complex":
path.down(20).m(0, -10).right(self.width).addTo(self)
else:
path.down(20).m(10, -20).down(20).m(-10,
- 10).right(self.width).addTo(self)
if self.label:
DiagramItem('text', attrs={
"x": x, "y": y - 15, "style": "text-anchor:start"}, text=self.label).addTo(self)
return self
def __repr__(self):
return 'Start(type=%r, label=%r)' % (self.type, self.label)
class End(DiagramItem):
def __init__(self, type="simple"):
DiagramItem.__init__(self, 'path')
self.width = 20
self.up = 10
self.down = 10
self.type = type
addDebug(self)
def format(self, x, y, _width):
if self.type == "simple":
self.attrs['d'] = 'M {0} {1} h 20 m -10 -10 v 20 m 10 -20 v 20'.format(
x, y)
elif self.type == "complex":
self.attrs['d'] = 'M {0} {1} h 20 m 0 -10 v 20'
return self
def __repr__(self):
return 'End(type=%r)' % self.type
class Terminal(DiagramItem):
def __init__(self, text, href=None, title=None):
DiagramItem.__init__(self, 'g', {'class': 'terminal'})
self.text = text
self.href = href
self.title = title
self.width = len(text) * C.CHAR_WIDTH + 20
self.up = 11
self.down = 11
self.needsSpace = True
addDebug(self)
def __repr__(self):
return 'Terminal(%r, href=%r, title=%r)' % (
self.text, self.href, self.title)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y).h(rightGap).addTo(self)
DiagramItem('rect', {'x': x + leftGap, 'y': y - 11, 'width': self.width,
'height': self.up + self.down, 'rx': 10, 'ry': 10}).addTo(self)
text = DiagramItem('text', {'x': x + width / 2, 'y': y + 4}, self.text)
if self.href is not None:
a = DiagramItem('a', {'xlink:href': self.href}, text).addTo(self)
text.addTo(a)
else:
text.addTo(self)
if self.title is not None:
DiagramItem('title', {}, self.title).addTo(self)
return self
class NonTerminal(DiagramItem):
def __init__(self, text, href=None, title=None):
DiagramItem.__init__(self, 'g', {'class': 'non-terminal'})
self.text = text
self.href = href
self.title = title
self.width = len(text) * C.CHAR_WIDTH + 20
self.up = 11
self.down = 11
self.needsSpace = True
addDebug(self)
def __repr__(self):
return 'NonTerminal(%r, href=%r, title=%r)' % (
self.text, self.href, self.title)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y).h(rightGap).addTo(self)
DiagramItem('rect', {'x': x + leftGap, 'y': y - 11, 'width': self.width,
'height': self.up + self.down}).addTo(self)
text = DiagramItem('text', {'x': x + width / 2, 'y': y + 4}, self.text)
if self.href is not None:
a = DiagramItem('a', {'xlink:href': self.href}, text).addTo(self)
text.addTo(a)
else:
text.addTo(self)
if self.title is not None:
DiagramItem('title', {}, self.title).addTo(self)
return self
class Comment(DiagramItem):
def __init__(self, text, href=None, title=None):
DiagramItem.__init__(self, 'g')
self.text = text
self.href = href
self.title = title
self.width = len(text) * C.COMMENT_CHAR_WIDTH + 10
self.up = 11
self.down = 11
self.needsSpace = True
addDebug(self)
def __repr__(self):
return 'Comment(%r, href=%r, title=%r)' % (
self.text, self.href, self.title)
def format(self, x, y, width):
leftGap, rightGap = determineGaps(width, self.width)
# Hook up the two sides if self is narrower than its stated width.
Path(x, y).h(leftGap).addTo(self)
Path(x + leftGap + self.width, y).h(rightGap).addTo(self)
text = DiagramItem(
'text', {'x': x + width / 2, 'y': y + 5, 'class': 'comment'}, self.text)
if self.href is not None:
a = DiagramItem('a', {'xlink:href': self.href}, text).addTo(self)
text.addTo(a)
else:
text.addTo(self)
if self.title is not None:
DiagramItem('title', {}, self.title).addTo(self)
return self
class Skip(DiagramItem):
def __init__(self):
DiagramItem.__init__(self, 'g')
self.width = 0
self.up = 0
self.down = 0
addDebug(self)
def format(self, x, y, width):
Path(x, y).right(width).addTo(self)
return self
def __repr__(self):
return 'Skip()'
def show_diagram(graph, log=False):
with io.StringIO() as f:
d = Diagram(graph)
if log:
print(d)
d.writeSvg(f.write)
mysvg = f.getvalue()
return mysvg
The content of this project is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. The source code that is part of the content, as well as the source code used to format and display that content is licensed under the MIT License. Last change: 2023-11-11 18:18:06+01:00 • Cite • Imprint