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 re
import io
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"&", '&amp;', str(text))
    text = re.sub(r"<", '&lt;', str(text))
    text = re.sub(r">", '&gt;', 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

Creative Commons License 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:00CiteImprint