diff --git a/.gitignore b/.gitignore
index 4c827d5..451cfe0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
/*.seq
*.svg
+*~
+*#
+sequence/__pycache__/*
diff --git a/README.md b/README.md
index 60db81e..b5f1ba4 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Sequence
A tool for creating SVG sequence diagrams from text input files.
-###Example
+### Example
Sequence lets you make sequence diagrams that look like this:
@@ -29,12 +29,11 @@ Browser, Browser, Rasterizes content
Browser, User, Presents content
@endphase
```
-###Data format
+### Data format
Sequence generates its SVG from a simple text data file. There are two basic entities in the data file: _steps_ and _phases_. Lines that begin with a hashtag (#) will be interpretted as comments, and have no effect on the image rendered. Empty lines are also ignored.
-####Steps
-
+#### Steps
Each _step_ in the sequence, where a step is defined as _an action between two actors_, is a single line in the input data file. (A step will be rendered as an arrow with text between actor columns.) The line must be comma-separated and contain either three or four values. The first value is the source actor, the one invoking the action. The second entry is the target actor, the one receiving the action. The third entry is a description of the action itself. The fourth value is optional, but if present will be used as the step's color.
For example:
@@ -54,8 +53,7 @@ Client, Server, Makes Request, green
**Note #2**: if the source actor and target actor are the same, then no arrow will be drawn between the actors. Instead a dot will appear in the source/target actor column with the text describing the action.
-####Phases
-
+#### Phases
A _phase_ is a collection of steps which will visually grouped together in the SVG. Phases are opened by the special directive in the data `@phase`. When opening a phase you will need to supply the name for the phase. This is just text included after the open phase directive. For example:
```
@@ -84,10 +82,9 @@ Bob, Bob, Decrypts message w/ priv key
which produces this SVG:
-
-
-####Ordering
+
+#### Ordering
By defaults, actors will be rendered from left to right in the order that they appear in the sequence file. However, if you want to specify a specific order for the actors in your diagram, you can do so by using the `@order` directive as the first line in your sequence file, followed by the (comma separated) specific actor order that you'd like to see. Comment and blank lines may preceed `@order`, but nothing else.
For instance, if we wanted to change the previous example to be in the order Alice, Bob, Keystore (instead of Bob, Keystore, Alice) we could add a `@order` directive at the top:
@@ -107,14 +104,24 @@ Bob, Bob, Decrypts message w/ priv key
which then produces this SVG:
-
+
Any actors not specified in the `@order` directive will appear in the order they appear in the rest of the file.
### Prerequisites
-You must have a python 2.7 installation and install the Python package `svgwrite` (e.g., `pip install svgwrite`)
+You must have a python 3 installation and install the Python package `svgwrite` (e.g., `pip install svgwrite`)
-###Usage
+### Usage
```
-python make_sequence.py in.seq > out.svg
+usage: make_sequence.py [-h] --in text_flow_filename --out svg_filename [--debug]
+
+A tool for creating SVG sequence diagrams from text input files.
+
+options:
+ -h, --help show this help message and exit
+ --in text_flow_filename, -i text_flow_filename
+ Flow text file input.
+ --out svg_filename, -o svg_filename
+ This is the result svg file.
+ --debug, -d Enable debug mode.
```
diff --git a/help/alice_bob.png b/help/alice_bob.png
new file mode 100644
index 0000000..e75cf42
Binary files /dev/null and b/help/alice_bob.png differ
diff --git a/help/alice_bob_ordered.png b/help/alice_bob_ordered.png
new file mode 100644
index 0000000..7c2224a
Binary files /dev/null and b/help/alice_bob_ordered.png differ
diff --git a/make_sequence.py b/make_sequence.py
index 18ddbc8..a36560b 100755
--- a/make_sequence.py
+++ b/make_sequence.py
@@ -1,190 +1,75 @@
-import svgwrite
+#!/usr/bin/env python3
+import argparse
import os.path
import sys
-
-class Colors:
- black = '#000000'
- gray = '#C0C0C0'
-
-class Phase:
- def __init__(self, name, color, action0):
- self.name = name
- self.color = color
- self.action0 = action0
- self.action1 = None
-
-class Sequence:
- def __init__(self, filename):
- self.filename = filename
- self.actors = []
- self.actors_map = {}
- self.actions = []
- self.phases = []
- # read actions into file
- self.parse_input_file(filename)
- # create drawing
- self.top_left = (100, 50)
- self.step_size = (100, 25)
- self.text_fudge = (0, 5)
- self.width = self.top_left[0] + (len(self.actors) + 1)*self.step_size[0]
- self.height = self.top_left[1] + (len(self.actions) + 1)*self.step_size[1]
- self.drawing = svgwrite.Drawing(size=(self.width, self.height))
- self.markers = {}
-
- def parse_input_file(self, filename):
- # read actions into file
- open_phases = []
- num_lines_processed = 0
- with open(filename) as f:
- for i, line in enumerate(f):
- line = line.strip()
- if len(line) == 0:
- # skip empty lines
- continue
- if line[0] == '#':
- # skip comments
- continue
- if line.startswith('@phase'):
- tokens = map(lambda s : s.strip(), line[len('@phase'):].split(','))
- assert len(tokens) > 0, '@phase must contain at least a name'
- phase_name = tokens[0]
- assert len(phase_name) > 0, '@phase must contain at least a name'
- phase_color = tokens[1] if len(tokens) > 1 else Colors.gray
- p = Phase(phase_name, phase_color, len(self.actions))
- open_phases.append(p)
- elif line.startswith('@endphase'):
- assert len(open_phases) > 0, '@endphase found with no corresponding opening @phase'
- p = open_phases.pop()
- p.action1 = len(self.actions)
- self.phases.append(p)
- elif line.startswith('@order'):
- assert num_lines_processed==0, '@order may only be on the first line!'
- tokens = map(lambda s : s.strip(), line[len('@order'):].split(','))
- for t in tokens:
- key = len(self.actors)
- self.actors_map[t] = key
- self.actors.append(t)
- else:
- self.parse_step(line)
- num_lines_processed += 1
- assert len(open_phases) == 0, '@phase opened without corresponding closing @endphase'
-
- def parse_step(self, line):
- tokens = map(lambda s : s.strip(), line.split(','))
- assert len(tokens) >= 3, 'line %i has less than 3 tokens' % (i)
- if len(tokens) < 3:
- return
- actor0 = tokens[0]
- actor1 = tokens[1]
- action = tokens[2]
- if actor0 not in self.actors_map:
- key = len(self.actors)
- self.actors_map[actor0] = key
- self.actors.append(actor0)
- if actor1 not in self.actors_map:
- key = len(self.actors)
- self.actors_map[actor1] = key
- self.actors.append(actor1)
- key0 = self.actors_map[actor0]
- key1 = self.actors_map[actor1]
- color = Colors.black
- if len(tokens) > 3:
- color = tokens[3].strip()
- self.actions.append((key0, key1, action, color))
-
-
- def build(self):
- self.create_header()
- self.create_actors()
- self.create_phases()
- self.create_actions()
- pass
-
- def to_string(self):
- return self.drawing.tostring()
-
- def create_header(self):
- text = 'filename: %s' % self.filename
- self.drawing.add(self.drawing.text(text, insert=(self.top_left[0] - 0.5*self.step_size[0], 0.5*self.top_left[1]), stroke='none', fill=Colors.gray, font_family="Helevetica", font_size="8pt", text_anchor="start"))
-
- def create_phases(self):
- for phase in self.phases:
- action0 = self.actions[phase.action0]
- action1 = self.actions[phase.action1 - 1]
-
- left = len(self.actors) + 1
- right = -1
- for action in self.actions[phase.action0:phase.action1]:
- mn = min(action[0:2])
- mx = max(action[0:2])
- left = mn if mn < left else left
- right = mx if mx > right else right
- x = self.top_left[0] + (left - 0.5)*self.step_size[0]
- y = self.top_left[1] + (phase.action0 + 0.5)*self.step_size[1]
- w = (right - left + 1)*self.step_size[0]
- h = (phase.action1 - phase.action0 - 0.125)*self.step_size[1]
- filled_rect = self.drawing.add(self.drawing.rect((x,y), (w,h)))
- filled_rect.fill(phase.color, None, 0.15)
- self.drawing.add(self.drawing.rect((x,y), (w,h), stroke=phase.color, stroke_width=1, fill='none'))
- transform = 'rotate(180,%i,%i) translate(6,0)' % (x, y+0.5*h)
- self.drawing.add(self.drawing.text(phase.name, insert=(x, y+0.5*h), stroke='none', fill=phase.color, font_family="Helevetica", font_size="6pt", text_anchor="middle", writing_mode="tb", transform=transform))
-
- def create_actors(self):
- x = self.top_left[0]
- y = self.top_left[1]
- for name in self.actors:
- self.drawing.add(self.drawing.text(name, insert=(x, y - self.text_fudge[1]), stroke='none', fill=Colors.black, font_family="Helevetica", font_size="8pt", text_anchor="middle"))
- line = self.drawing.add(self.drawing.line((x, y), (x, self.height), stroke=Colors.gray, stroke_width=1))
- line.dasharray([5, 5])
- x += self.step_size[0]
-
- def create_actions(self):
- markers = {}
- # add actions
- y = self.top_left[1] + self.step_size[1]
- for (a0, a1, act, color) in self.actions:
- x0 = self.top_left[0] + a0*self.step_size[0]
- x1 = self.top_left[0] + a1*self.step_size[0]
- if a0 == a1:
- self.drawing.add(self.drawing.circle((x0, y), r=2, fill=color))
- else:
- start_marker, end_marker = self.get_markers(color)
- assert start_marker is not None
- assert end_marker is not None
- line = self.drawing.add(self.drawing.line((x0, y), (x1, y), stroke=color, stroke_width=1))
- line['marker-start'] = start_marker.get_funciri()
- line['marker-end'] = end_marker.get_funciri()
- self.drawing.add(self.drawing.text(act, insert=(0.5*(x0 + x1), y - self.text_fudge[1]), stroke='none', fill=color, font_family="Helevetica", font_size="6pt", text_anchor="middle"))
- y += self.step_size[1]
-
- def get_markers(self, color):
- # create or get marker objects
- start_marker, end_marker = None, None
- if color in self.markers:
- start_marker, end_marker = self.markers[color]
- else:
- start_marker = self.drawing.marker(insert=(2,2), size=(10,10), orient='auto')
- start_marker.add(self.drawing.circle((2,2), r=2, fill=color))
- self.drawing.defs.add(start_marker)
- end_marker = self.drawing.marker(insert=(6,3), size=(10,10), orient='auto')
- end_marker.add(self.drawing.path("M0,0 L0,7 L6,3 L0,0", fill=color))
- self.drawing.defs.add(end_marker)
- self.markers[color] = (start_marker, end_marker)
- return start_marker, end_marker
-
-def usage():
- print 'Usage: ./make_sequence.py > '
- sys.exit(-1)
+import logging
+from sequence import sequence
+
+# Default logging configuration
+logging.basicConfig(level=logging.INFO)
+
+# Get logger object
+logger = logging.getLogger(__name__)
+
+class MakeSequence(sequence.Sequence):
+ def __init__(self, filename, loglevel=logging.INFO):
+ self.sequence_lines = []
+ # read actions into list
+ self.parse_input_file(filename)
+ # create drawing
+ super().__init__(self.sequence_lines, loglevel=loglevel)
+
+
+ def parse_input_file(self, filename):
+ # read actions into file
+ open_phases = []
+ num_lines_processed = 0
+ with open(filename, 'rt') as f:
+ for i, line in enumerate(f):
+ line = line.strip()
+ self.sequence_lines.append(line)
+
+def main():
+ logging_level = logging.INFO
+ # Parameters
+ cmd_param = argparse.ArgumentParser(
+ description='A tool for creating SVG sequence diagrams from text input files.')
+ cmd_param.add_argument('--in', '-i', dest='inputfile',
+ metavar='text_flow_filename',
+ type=str, required=True,
+ help='Flow text file input.')
+ cmd_param.add_argument('--out', '-o', dest='outputfile',
+ metavar='svg_filename', type=str,
+ required=True,
+ help='This is the result svg file.')
+ cmd_param.add_argument('--debug', '-d', dest='debug', default=False,
+ required=False, action='store_true',
+ help='Enable debug mode.')
+
+ param = cmd_param.parse_args()
+ if param.debug:
+ logging_level = logging.DEBUG
+ # change logger verbose level
+ logger.setLevel(logging.DEBUG)
+ txtin_flow_filename = param.inputfile
+ svgout_flow_filename = param.outputfile
+
+ logger.info('Process start')
+
+ if not os.path.isfile(txtin_flow_filename):
+ print(f'File {txtin_flow_filename} not found')
+ sys.exit(-1)
+
+ logger.info('Processing sequence text file')
+ seq = MakeSequence(txtin_flow_filename, loglevel=logging_level)
+ seq.build()
+
+ logger.debug('SVG file: %s' %seq.to_string())
+
+ with open(svgout_flow_filename, 'w') as f:
+ print(seq.to_string(), file=f)
+ logger.info('SVG file saved')
if __name__ == '__main__':
- if len(sys.argv) < 2:
- print 'missing input filename'
- usage()
- filename = sys.argv[1]
- if not os.path.isfile(filename):
- print 'file %s not found' % filename
- sys.exit(-1)
- seq = Sequence(sys.argv[1])
- seq.build()
- print seq.to_string()
\ No newline at end of file
+ main()
diff --git a/sequence/__init__.py b/sequence/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sequence/colors.py b/sequence/colors.py
new file mode 100644
index 0000000..526e67c
--- /dev/null
+++ b/sequence/colors.py
@@ -0,0 +1,8 @@
+import logging
+
+# Get logger object
+logger = logging.getLogger(__name__)
+
+class Colors:
+ black = '#000000'
+ gray = '#C0C0C0'
diff --git a/sequence/phase.py b/sequence/phase.py
new file mode 100644
index 0000000..f36f5e3
--- /dev/null
+++ b/sequence/phase.py
@@ -0,0 +1,14 @@
+import logging
+
+# Get logger object
+logger = logging.getLogger(__name__)
+
+class PhaseError(Exception):
+ pass
+
+class Phase(object):
+ def __init__(self, name, color, action0):
+ self.name = name
+ self.color = color
+ self.action0 = action0
+ self.action1 = None
diff --git a/sequence/sequence.py b/sequence/sequence.py
new file mode 100644
index 0000000..3d2fc02
--- /dev/null
+++ b/sequence/sequence.py
@@ -0,0 +1,172 @@
+import svgwrite
+import logging
+from .colors import Colors
+from .phase import Phase
+
+# Get logger object
+logger = logging.getLogger(__name__)
+
+class Sequence(object):
+ def __init__(self, sequencelist, loglevel=logging.INFO):
+ logger.setLevel(loglevel)
+ self.sequencelist = sequencelist
+ self.actors = []
+ self.actors_map = {}
+ self.actions = []
+ self.phases = []
+ # read actions into file
+ self.parse_input_list(sequencelist)
+ # create drawing
+ self.top_left = (100, 50)
+ self.step_size = (100, 25)
+ self.text_fudge = (0, 5)
+ self.width = self.top_left[0] + (len(self.actors) + 1)*self.step_size[0]
+ self.height = self.top_left[1] + (len(self.actions) + 1)*self.step_size[1]
+ self.drawing = svgwrite.Drawing(size=(self.width, self.height))
+ self.markers = {}
+
+ def parse_input_list(self, sequencelist):
+ # read actions into file
+ open_phases = []
+ num_lines_processed = 0
+ for line in self.sequencelist:
+ logger.debug('New line: %s' % line)
+ if len(line) == 0:
+ logger.debug('skip empty lines')
+ continue
+ if line[0] == '#':
+ logger.debug('skip comments')
+ continue
+ if line.startswith('@phase'):
+ logger.debug('opens a phase')
+ tokens = [s.strip() for s in line[len('@phase'):].split(',')]
+ assert len(tokens) > 0, '@phase must contain at least a name'
+ phase_name = tokens[0]
+ assert len(phase_name) > 0, '@phase must contain at least a name'
+ phase_color = tokens[1] if len(tokens) > 1 else Colors.gray
+ p = Phase(phase_name, phase_color, len(self.actions))
+ open_phases.append(p)
+ logger.debug('total open phases: %d' % len(open_phases))
+ elif line.startswith('@endphase'):
+ logger.debug('ends a phase')
+ assert len(open_phases) > 0, '@endphase found with no corresponding opening @phase'
+ p = open_phases.pop()
+ p.action1 = len(self.actions)
+ self.phases.append(p)
+ elif line.startswith('@order'):
+ logger.debug('order line')
+ assert num_lines_processed==0, '@order may only be on the first line!'
+ tokens = [s.strip() for s in line[len('@order'):].split(',')]
+ for t in tokens:
+ key = len(self.actors)
+ self.actors_map[t] = key
+ self.actors.append(t)
+ else:
+ logger.debug('process a flow line')
+ self.parse_step(line)
+ num_lines_processed += 1
+ if len(open_phases) != 0:
+ raise PhaseError('@phase opened without corresponding closing @endphase')
+
+ def parse_step(self, line):
+ tokens = [s.strip() for s in line.split(',')]
+ assert len(tokens) >= 3, 'line %i has less than 3 tokens' % (i)
+ if len(tokens) < 3:
+ return
+ actor0 = tokens[0]
+ actor1 = tokens[1]
+ action = tokens[2]
+ if actor0 not in self.actors_map:
+ key = len(self.actors)
+ self.actors_map[actor0] = key
+ self.actors.append(actor0)
+ if actor1 not in self.actors_map:
+ key = len(self.actors)
+ self.actors_map[actor1] = key
+ self.actors.append(actor1)
+ key0 = self.actors_map[actor0]
+ key1 = self.actors_map[actor1]
+ color = Colors.black
+ if len(tokens) > 3:
+ color = tokens[3].strip()
+ self.actions.append((key0, key1, action, color))
+
+
+ def build(self):
+ self.create_header()
+ self.create_actors()
+ self.create_phases()
+ self.create_actions()
+ pass
+
+ def to_string(self):
+ return self.drawing.tostring()
+
+ def create_header(self):
+ text = 'sequencelist: %s' % self.sequencelist
+ self.drawing.add(self.drawing.text(text, insert=(self.top_left[0] - 0.5*self.step_size[0], 0.5*self.top_left[1]), stroke='none', fill=Colors.gray, font_family="Helevetica", font_size="8pt", text_anchor="start"))
+
+ def create_phases(self):
+ for phase in self.phases:
+ action0 = self.actions[phase.action0]
+ action1 = self.actions[phase.action1 - 1]
+
+ left = len(self.actors) + 1
+ right = -1
+ for action in self.actions[phase.action0:phase.action1]:
+ mn = min(action[0:2])
+ mx = max(action[0:2])
+ left = mn if mn < left else left
+ right = mx if mx > right else right
+ x = self.top_left[0] + (left - 0.5)*self.step_size[0]
+ y = self.top_left[1] + (phase.action0 + 0.5)*self.step_size[1]
+ w = (right - left + 1)*self.step_size[0]
+ h = (phase.action1 - phase.action0 - 0.125)*self.step_size[1]
+ filled_rect = self.drawing.add(self.drawing.rect((x,y), (w,h)))
+ filled_rect.fill(phase.color, None, 0.15)
+ self.drawing.add(self.drawing.rect((x,y), (w,h), stroke=phase.color, stroke_width=1, fill='none'))
+ transform = 'rotate(180,%i,%i) translate(6,0)' % (x, y+0.5*h)
+ self.drawing.add(self.drawing.text(phase.name, insert=(x, y+0.5*h), stroke='none', fill=phase.color, font_family="Helevetica", font_size="6pt", text_anchor="middle", writing_mode="tb", transform=transform))
+
+ def create_actors(self):
+ x = self.top_left[0]
+ y = self.top_left[1]
+ for name in self.actors:
+ self.drawing.add(self.drawing.text(name, insert=(x, y - self.text_fudge[1]), stroke='none', fill=Colors.black, font_family="Helevetica", font_size="8pt", text_anchor="middle"))
+ line = self.drawing.add(self.drawing.line((x, y), (x, self.height), stroke=Colors.gray, stroke_width=1))
+ line.dasharray([5, 5])
+ x += self.step_size[0]
+
+ def create_actions(self):
+ markers = {}
+ # add actions
+ y = self.top_left[1] + self.step_size[1]
+ for (a0, a1, act, color) in self.actions:
+ x0 = self.top_left[0] + a0*self.step_size[0]
+ x1 = self.top_left[0] + a1*self.step_size[0]
+ if a0 == a1:
+ self.drawing.add(self.drawing.circle((x0, y), r=2, fill=color))
+ else:
+ start_marker, end_marker = self.get_markers(color)
+ assert start_marker is not None
+ assert end_marker is not None
+ line = self.drawing.add(self.drawing.line((x0, y), (x1, y), stroke=color, stroke_width=1))
+ line['marker-start'] = start_marker.get_funciri()
+ line['marker-end'] = end_marker.get_funciri()
+ self.drawing.add(self.drawing.text(act, insert=(0.5*(x0 + x1), y - self.text_fudge[1]), stroke='none', fill=color, font_family="Helevetica", font_size="6pt", text_anchor="middle"))
+ y += self.step_size[1]
+
+ def get_markers(self, color):
+ # create or get marker objects
+ start_marker, end_marker = None, None
+ if color in self.markers:
+ start_marker, end_marker = self.markers[color]
+ else:
+ start_marker = self.drawing.marker(insert=(2,2), size=(10,10), orient='auto')
+ start_marker.add(self.drawing.circle((2,2), r=2, fill=color))
+ self.drawing.defs.add(start_marker)
+ end_marker = self.drawing.marker(insert=(6,3), size=(10,10), orient='auto')
+ end_marker.add(self.drawing.path("M0,0 L0,7 L6,3 L0,0", fill=color))
+ self.drawing.defs.add(end_marker)
+ self.markers[color] = (start_marker, end_marker)
+ return start_marker, end_marker