#!/usr/bin/python from __future__ import print_function import re import argparse from ConfigParser import ConfigParser import sys import ast import os import io import copy import subprocess import collections import cairo import math try: import pango import pangocairo use_pygobject = False except ImportError: import gi gi.require_version('PangoCairo', '1.0') gi.require_version('Pango', '1.0') from gi.repository import Pango as pango from gi.repository import PangoCairo as pangocairo use_pygobject = True try: import webcolors have_webcolors = True except ImportError: have_webcolors = False __version__ = '1.1' def cairo_font(tk_font): family, size, weight = tk_font return pango.FontDescription('{} {} {}'.format(family, weight, size)) def cairo_text_bbox(text, font_params, scale=1.0): surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 8, 8) ctx = cairo.Context(surf) # The scaling must match the final context. # If not there can be a mismatch between the computed extents here # and those generated for the final render. ctx.scale(scale, scale) font = cairo_font(font_params) if use_pygobject: layout = pangocairo.create_layout(ctx) pctx = layout.get_context() fo = cairo.FontOptions() fo.set_antialias(cairo.ANTIALIAS_SUBPIXEL) pangocairo.context_set_font_options(pctx, fo) layout.set_font_description(font) layout.set_text(text, len(text)) re = layout.get_pixel_extents()[1] extents = (re.x, re.y, re.x + re.width, re.y + re.height) else: # pyGtk pctx = pangocairo.CairoContext(ctx) pctx.set_antialias(cairo.ANTIALIAS_SUBPIXEL) layout = pctx.create_layout() layout.set_font_description(font) layout.set_text(text) #print('@@ EXTENTS:', layout.get_pixel_extents()[1]) extents = layout.get_pixel_extents()[1] w = extents[2] - extents[0] h = extents[3] - extents[1] x0 = - w // 2.0 y0 = - h // 2.0 return [x0,y0, x0+w,y0+h] class NodeStyle(object): def __init__(self, name, node_style=None): self.name = name self.pattern = '.' self.shape = 'bubble' self.text_mod = None self.text_mod_func = None self.font = ['Sans', 14, 'bold'] self.text_color = (0,0,0) self.fill = (144,164,174) if node_style is None: node_style = {} for k,v in node_style.iteritems(): if hasattr(self, k): # Check for color styles if k.endswith('fill') or k.endswith('color'): v = convert_color(v) setattr(self, k, v) if self.text_mod is not None: self.text_mod_func = eval(self.text_mod) # WARNING: eval() on user input def __repr__(self): keys = ( 'pattern', 'shape', 'text_mod', 'font', 'text_color', 'fill') ini_keys = ['{} = {}'.format(k, repr(getattr(self, k))) for k in keys] return '[{}]\n{}\n'.format(self.name, '\n'.join(ini_keys)) class DrawStyle(object): def __init__(self, styles=None, node_styles=[]): # Set defaults self.line_width = 2 self.line_color = (0,0,0) self.outline_width = 2 self.padding = 5 self.max_radius = 9 self.h_sep = 17 self.v_sep = 9 self.arrows = True self.title_pos = 'tl' self.bullet_fill = (255,255,255) self.text_color = (0,0,0) self.shadow = True self.shadow_fill = (0,0,0, 127) self.title_font = ('Sans', 22, 'bold') # Load any styles if styles is None: styles = {} for k,v in styles.iteritems(): if hasattr(self, k): # Check for color styles if k.endswith('_fill') or k.endswith('_color'): v = convert_color(v) setattr(self, k, v) # Set node style defaults if len(node_styles) == 0: node_styles = [ ('bubble', {'shape':'bubble', 'pattern':'^\w', 'font':('Sans', 14, 'bold'), 'fill':(179, 229, 252)}), ('box', {'shape':'box', 'pattern':'^/', 'font':('Times', 14, 'italic'), 'fill':(144, 164, 174), 'text_mod':'lambda txt: txt[1:]'}), ('token', {'shape':'bubble', 'pattern':'.', 'font':('Sans', 16, 'bold'), 'fill':(179, 229, 252)}), ] for _, ns in node_styles: if 'text_color' not in ns: ns['text_color'] = self.text_color # Init node styles self.node_styles = [NodeStyle(name, ns) for name,ns in node_styles] def __repr__(self): keys = ('line_width', 'outline_width', 'padding', 'line_color', 'max_radius', 'h_sep', 'v_sep', 'arrows', 'title_pos', 'bullet_fill', 'text_color', 'shadow', 'shadow_fill', 'title_font') ini_keys = ['{} = {}'.format(k, repr(getattr(self, k))) for k in keys] return '[style]\n{}\n'.format('\n'.join(ini_keys)) def convert_color(c): rgb = c # Check for hex string try: rgb = hex_to_rgb(rgb) except (TypeError, ValueError): pass # Check for named color if have_webcolors: try: rgb = webcolors.name_to_rgb(rgb) except AttributeError: pass # Restrict to valid range rgb = tuple(0 if c < 0 else 255 if c > 255 else c for c in rgb) return rgb def rgb_to_hex(rgb): return '#{:02X}{:02X}{:02X}'.format(*rgb[:3]) def hex_to_rgb(hex_color): v = int(hex_color[1:], 16) b = v & 0xFF g = (v >> 8) & 0xFF r = (v >> 16) & 0xFF return (r,g,b) def rgb_to_cairo(rgb): if len(rgb) == 4: r,g,b,a = rgb return (r / 255.0, g / 255.0, b / 255.0, a / 255.0) else: r,g,b = rgb return (r / 255.0, g / 255.0, b / 255.0, 1.0) def parse_style_config(fname): if os.path.exists(fname): print('Reading styles from "{}"'.format(fname)) cp = ConfigParser() cp.read(fname) styles = {} # Extract all sections into a dictionary sd = collections.OrderedDict() for sname in cp.sections(): opts = {} for opt in cp.options(sname): opts[opt] = ast.literal_eval(cp.get(sname, opt)) sd[sname] = opts # Style section is main set of style settings if 'style' in sd: styles = sd['style'] # All remaining sections are node styles node_styles = [(k,v) for k,v in sd.iteritems() if k != 'style'] # Simplify title position if 'title_pos' in styles: pos = styles['title_pos'].lower() pos = pos.replace('top', 't') pos = pos.replace('bottom', 'b') pos = pos.replace('left', 'l') pos = pos.replace('right', 'r') pos = pos.replace('center', 'c') pos = pos.replace('-', '') pos = pos.replace(' ', '') styles['title_pos'] = pos return DrawStyle(styles, node_styles) class BaseShape(object): def __init__(self): self.options = {} self._bbox = [0,0,1,1] self.tags = set() @property def points(self): return tuple(self._bbox) @property def bbox(self): if 'width' in self.options: w = self.options['width'] / 2 else: w = 1 x0 = min(self._bbox[0], self._bbox[2]) x1 = max(self._bbox[0], self._bbox[2]) y0 = min(self._bbox[1], self._bbox[3]) y1 = max(self._bbox[1], self._bbox[3]) x0 -= w x1 += w y0 -= w y1 += w return (x0,y0,x1,y1) def is_tagged(self, item): return item in self.tags def update_tags(self): if 'tags' in self.options: self.tags = self.tags.union(self.options['tags']) del self.options['tags'] def move(self, dx, dy): self._bbox[0] += dx self._bbox[1] += dy self._bbox[2] += dx self._bbox[3] += dy def dtag(self, tag=None): if tag is None: self.tags.clear() else: self.tags.discard(tag) def addtag(self, tag=None): if tag is not None: self.tags.add(tag) def draw(self, c): pass class LineShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() class RectShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() class OvalShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() class ArcShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() @property def bbox(self): if 'width' in self.options: w = self.options['width'] else: w = 0 # Calculate bounding box for arc segment x0, y0, x1, y1 = self.points xc = (x0 + x1) / 2.0 yc = (y0 + y1) / 2.0 rad = abs(x1 - x0) / 2.0 rad += w / 2.0 start = self.options['start'] % 360 extent = self.options['extent'] stop = (start + extent) % 360 if extent < 0: start, stop = stop, start # Swap points so we can rotate CCW if stop < start: stop += 360 # Make stop greater than start angles = [start, stop] # Find the extrema of the circle included in the arc ortho = (start // 90) * 90 + 90 while ortho < stop: angles.append(ortho) ortho += 90 # Rotate CCW # Convert all extrema points to cartesian points = [(rad * math.cos(math.radians(a)), -rad * math.sin(math.radians(a))) for a in angles] points = zip(*points) bx0 = min(points[0]) + xc by0 = min(points[1]) + yc bx1 = max(points[0]) + xc by1 = max(points[1]) + yc #print('@@ ARC BB:', (bx0,by0,bx1,by1), rad, angles, start, extent) return (bx0,by0,bx1,by1) class TextShape(BaseShape): text_id = 1 def __init__(self, x0, y0, text_bbox, options): BaseShape.__init__(self) self.options = options if 'anchor' in options: anchor = options['anchor'].lower() else: anchor = 'l' bx0,by0, bx1,by1 = text_bbox(options['text'], options['font']) w = bx1 - bx0 h = by1 - by0 if anchor == 'c': x0 -= w//2 y0 -= h//2 self._bbox = [x0, y0, x0+w, y0+h] #self._bbox = text_bbox(options['text'], options['font']) self.update_tags() #print('## NEW TEXT:', x0, y0, self._bbox, anchor) class BubbleShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() class BoxBubbleShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() class HexBubbleShape(BaseShape): def __init__(self, x0, y0, x1, y1, options): BaseShape.__init__(self) self.options = options self._bbox = [x0, y0, x1, y1] self.update_tags() def cairo_draw_arrow(head, tail, fill, c): width = c.get_line_width() c.save() dy = head[1] - tail[1] dx = head[0] - tail[0] angle = math.atan2(dy,dx) c.translate(head[0],head[1]) c.rotate(angle) c.scale(width, width) # Now positioned to draw arrow at 0,0 with point facing right apath = [(-4,0), (-4.5,2), (0,0)] mirror = [(x,-y) for x, y in reversed(apath[1:-1])] # Mirror central points apath.extend(mirror) c.move_to(*apath[0]) for p in apath[1:]: c.line_to(*p) c.close_path() c.set_source_rgba(*fill) c.fill() c.restore() def cairo_draw_text(x, y, text, font, text_color, c): c.save() #print('## TEXT COLOR:', text_color) c.set_source_rgba(*rgb_to_cairo(text_color)) font = cairo_font(font) c.translate(x, y) if use_pygobject: layout = pangocairo.create_layout(c) pctx = layout.get_context() fo = cairo.FontOptions() fo.set_antialias(cairo.ANTIALIAS_SUBPIXEL) pangocairo.context_set_font_options(pctx, fo) layout.set_font_description(font) layout.set_text(text, len(text)) pangocairo.update_layout(c, layout) pangocairo.show_layout(c, layout) else: # pyGtk pctx = pangocairo.CairoContext(c) pctx.set_antialias(cairo.ANTIALIAS_SUBPIXEL) layout = pctx.create_layout() layout.set_font_description(font) layout.set_text(text) pctx.update_layout(layout) pctx.show_layout(layout) c.restore() def cairo_draw_shape(shape, c, styles): default_pen = rgb_to_cairo(styles.line_color) c.set_source_rgba(*default_pen) if 'width' in shape.options: width = shape.options['width'] else: width = 2.0 c.set_line_width(width) text_color = shape.options['text_color'] if 'text_color' in shape.options \ else styles.text_color if isinstance(shape, TextShape): x0, y0, x1, y1 = shape.points cairo_draw_text(x0, y0, shape.options['text'], shape.options['font'], text_color, c) elif isinstance(shape, LineShape): x0, y0, x1, y1 = shape.points if 'arrow' not in shape.options or shape.options['arrow'] is None: c.move_to(x0,y0) c.line_to(x1,y1) c.stroke() else: # Draw line with arrowhead if shape.options['arrow'] == 'first': head = x0, y0 tail = x1, y1 else: # Last head = x1, y1 tail = x0, y0 # Adjust head point to show gaps between lines length = math.sqrt(abs(x1 - x0)**2 + abs(y1 - y0)**2) length -= 3 angle = math.atan2(head[1] - tail[1], head[0] - tail[0]) #print('# LEN:', length, angle * 180 / math.pi) c.save() c.translate(*tail) c.rotate(angle) c.move_to(0,0) c.line_to(length,0) c.stroke() c.restore() cairo_draw_arrow(head, tail, default_pen, c) elif isinstance(shape, RectShape): x0, y0, x1, y1 = shape.points c.rectangle(x0,y0, x1-x0,y1-y0) stroke = True if shape.options['width'] > 0 else False #print('%% RECT:', stroke, shape.options) if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: c.set_source_rgba(*default_pen) c.stroke() elif isinstance(shape, BubbleShape): x0, y0, x1, y1 = shape.points stroke = True if shape.options['width'] > 0 else False #print('%% BUBBLE:', stroke, shape.points, shape.options) rad = (y1 - y0) / 2.0 left = x0 + rad right = x1 - rad xc = (x0 + x1) / 2 yc = (y0 + y1) / 2.0 if abs(right - left) <= 1: # Circular bubble c.arc(xc,yc, rad, 0, 2 * math.pi) else: # Rounded box c.move_to(xc, y1) c.line_to(right, y1) c.arc_negative(right,yc, rad, math.pi / 2, -math.pi / 2) c.line_to(left, y0) c.arc_negative(left,yc, rad, -math.pi / 2, math.pi / 2) c.close_path() if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: c.set_source_rgba(*default_pen) c.stroke() # # Add text bounding box # w = x1-x0 # h = y1-y0 # bx0, by0, bx1, by1 = cairo_text_bbox(shape.options['text'], shape.options['font']) # bw = abs(bx1-bx0) # bh = abs(by1-by0) # c.rectangle(x0 + (w - bw)//2, y0 + (h - bh)//2, bw, bh) # c.set_source_rgba(*default_pen) # c.stroke() # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] x += (x0 + x1) / 2 y += (y0 + y1) / 2 cairo_draw_text(x, y, shape.options['text'], shape.options['font'], text_color, c) elif isinstance(shape, HexBubbleShape): x0, y0, x1, y1 = shape.points stroke = True if shape.options['width'] > 0 else False #print('%% HEXBUBBLE:', stroke, shape.points, shape.options) rad = (y1 - y0) / 2.0 left = x0 + rad right = x1 - rad rpad = rad * 0.5 xc = (x0 + x1) / 2 yc = (y0 + y1) / 2.0 if abs(right - left) <= 1: # Round hex left = xc right = xc c.move_to(xc, y1) c.line_to(right+rpad, y1) c.line_to(right+rad, yc) # Right point c.line_to(right+rpad, y0) c.line_to(left-rpad, y0) c.line_to(left-rad, yc) # Left point c.line_to(left-rpad, y1) c.close_path() if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: c.set_source_rgba(*default_pen) c.stroke() # # Add text bounding box # w = x1-x0 # h = y1-y0 # bx0, by0, bx1, by1 = cairo_text_bbox(shape.options['text'], shape.options['font']) # bw = abs(bx1-bx0) # bh = abs(by1-by0) # c.rectangle(x0 + (w - bw)//2, y0 + (h - bh)//2, bw, bh) # c.set_source_rgba(*default_pen) # c.stroke() # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] x += (x0 + x1) / 2 y += (y0 + y1) / 2 cairo_draw_text(x, y, shape.options['text'], shape.options['font'], text_color, c) elif isinstance(shape, BoxBubbleShape): x0, y0, x1, y1 = shape.points w = x1-x0 h = y1-y0 c.rectangle(x0,y0, w,h) stroke = True if shape.options['width'] > 0 else False #print('%% BOXBUBBLE:', stroke, shape.options) if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: c.set_source_rgba(*default_pen) c.stroke() # # Add text bounding box # bx0, by0, bx1, by1 = cairo_text_bbox(shape.options['text'], shape.options['font']) # bw = abs(bx1-bx0) # bh = abs(by1-by0) # c.rectangle(x0 + (w - bw)//2, y0 + (h - bh)//2, bw, bh) # c.set_source_rgba(*default_pen) # c.stroke() # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] x += (x0 + x1) / 2 y += (y0 + y1) / 2 cairo_draw_text(x, y, shape.options['text'], shape.options['font'], text_color, c) elif isinstance(shape, OvalShape): x0, y0, x1, y1 = shape.points xc = (x0 + x1) / 2 yc = (y0 + y1) / 2 rad = (x1 - x0) / 2 c.arc(xc,yc, rad, 0, 2 * math.pi) stroke = True if shape.options['width'] > 0 else False if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: c.set_source_rgba(*default_pen) c.stroke() elif isinstance(shape, ArcShape): x0, y0, x1, y1 = shape.points xc = (x0 + x1) / 2 yc = (y0 + y1) / 2 rad = (x1 - x0) / 2 start = shape.options['start'] extent = shape.options['extent'] # Start and end angles sa = -math.radians(start) ea = -math.radians(start + extent) # Tk has opposite angle convention from Cairo # Positive extent is a negative rotation in Cairo # Negative extent is a positive rotation in Cairo # Arc fill if 'fill' in shape.options: c.move_to(xc,yc) if extent >= 0: c.arc_negative(xc,yc, rad, sa, ea) else: c.arc(xc,yc, rad, sa, ea) c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) c.fill() # Stroke arc segment c.new_sub_path() if extent >= 0: c.arc_negative(xc,yc, rad, sa, ea) else: c.arc(xc,yc, rad, sa, ea) c.set_source_rgba(*default_pen) c.stroke() #print('%% ARC:', xc, yc, rad, start, extent) # # Draw bounding box # bx0, by0, bx1, by1 = shape.bbox # bw = abs(bx1-bx0) # bh = abs(by1-by0) # c.rectangle(bx0, by0, bw, bh) # c.set_source_rgba(*rgb_to_cairo((255,0,0,127))) # c.set_line_width(1.0) # c.stroke() def xml_escape(txt): txt = txt.replace('&', '&') txt = txt.replace('<', '<') txt = txt.replace('>', '>') txt = txt.replace('"', '"') return txt def svg_draw_shape(shape, fh, styles): default_pen = rgb_to_hex(styles.line_color) if 'width' in shape.options: width = shape.options['width'] else: width = 2.0 attrs = { 'stroke': 'none', 'fill': '#fff' } if width > 0: attrs['stroke-width'] = width attrs['stroke'] = default_pen if 'fill' in shape.options: attrs['fill'] = rgb_to_hex(shape.options['fill']) if len(shape.options['fill']) == 4: attrs['fill-opacity'] = shape.options['fill'][3] / 255.0 if isinstance(shape, TextShape): x0, y0, x1, y1 = shape.points x = (x0 + x1) / 2 # Center text y = y1 - 10 # FIXME: Adjust for baseline offset font_name = shape.options['font_name'] fh.write(u'<text class="{}" x="{}" y="{}">{}</text>\n'.format(font_name, x, y, xml_escape(shape.options['text']))) elif isinstance(shape, LineShape): x0, y0, x1, y1 = shape.points # We don't need a fill attribute for lines del attrs['fill'] attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) if 'arrow' not in shape.options or shape.options['arrow'] is None: fh.write(u'<line x1="{}" y1="{}" x2="{}" y2="{}" {} />\n'.format( x0,y0,x1,y1, attributes)) else: # Draw line with arrowhead attributes += ' marker-end="url(#arrow)"' if shape.options['arrow'] == 'first': head = x0, y0 tail = x1, y1 else: # Last head = x1, y1 tail = x0, y0 # Move end point back to account for arrow marker length = math.sqrt(abs(x1 - x0)**2 + abs(y1 - y0)**2) length -= 4 angle = math.atan2(head[1] - tail[1], head[0] - tail[0]) head = (tail[0] + length * math.cos(angle), tail[1] + length * math.sin(angle)) fh.write(u'<line x1="{}" y1="{}" x2="{}" y2="{}" {} />\n'.format( tail[0],tail[1],head[0],head[1], attributes)) elif isinstance(shape, RectShape): x0, y0, x1, y1 = shape.points #c.rectangle(x0,y0, x1-x0,y1-y0) attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) fh.write(u'<rect x="{}" y="{}" width="{}" height="{}" {}/>\n'.format( x0,y0, x1-x0, y1-y0, attributes)) elif isinstance(shape, BubbleShape): x0, y0, x1, y1 = shape.points attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) rad = (y1 - y0) / 2.0 left = x0 + rad right = x1 - rad xc = (x0 + x1) / 2 yc = (y0 + y1) / 2.0 if abs(right - left) <= 1: # Circular bubble fh.write(u'<circle cx="{}" cy="{}" r="{}" {}/>\n'.format(xc, yc, rad, attributes)) else: # Rounded box fh.write(u'<path d="M{},{} A{},{} 0 0,1 {},{} H{} A{},{} 0 0,1 {},{} z" {}/>\n'.format(left,y1, rad,rad,left,y0, right, rad,rad,right,y1, attributes)) # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] th = abs(y) # y += (y0 + y1) / 2 x = (x0 + x1) / 2 # Center in bubble y = ((y0 + y1) / 2) + th / 2 txt = xml_escape(shape.options['text']) font_name = shape.options['font_name'] if 'href' in shape.options and shape.options['href'] is not None: # Hyperlink href = shape.options['href'] fh.write(u'<a xlink:href="{}" target="_parent">\n <text class="{} link" x="{}" y="{}">{}</text></a>\n'.format(href, font_name, x, y, txt)) else: fh.write(u'<text class="{}" x="{}" y="{}">{}</text>\n'.format(font_name, x, y, txt)) elif isinstance(shape, HexBubbleShape): x0, y0, x1, y1 = shape.points attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) rad = (y1 - y0) / 2.0 left = x0 + rad right = x1 - rad rpad = rad * 0.5 xc = (x0 + x1) / 2 yc = (y0 + y1) / 2.0 if abs(right - left) <= 1: # Round hex left = xc right = xc fh.write(u'<path d="M{},{} H{} L{},{} L{},{} H{} L{},{} z" {}/>\n'.format(left-rpad,y1, right+rpad, right+rad,yc, right+rpad,y0, left-rpad, left-rad,yc, attributes)) # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] th = abs(y) # y += (y0 + y1) / 2 x = (x0 + x1) / 2 # Center in bubble y = ((y0 + y1) / 2) + th / 2 txt = xml_escape(shape.options['text']) font_name = shape.options['font_name'] if 'href' in shape.options and shape.options['href'] is not None: # Hyperlink href = shape.options['href'] fh.write(u'<a xlink:href="{}" target="_parent">\n <text class="{} link" x="{}" y="{}">{}</text></a>\n'.format(href, font_name, x, y, txt)) else: fh.write(u'<text class="{}" x="{}" y="{}">{}</text>\n'.format(font_name, x, y, txt)) elif isinstance(shape, BoxBubbleShape): x0, y0, x1, y1 = shape.points attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) fh.write(u'<rect x="{}" y="{}" width="{}" height="{}" {}/>\n'.format( x0,y0, x1-x0, y1-y0, attributes)) # Add the text if 'text' in shape.options: x, y = shape.options['text_pos'] th = abs(y) #y += (y0 + y1) / 2 x = (x0 + x1) / 2 # Center in bubble y = ((y0 + y1) / 2) + th / 2 txt = xml_escape(shape.options['text']) font_name = shape.options['font_name'] if 'href' in shape.options and shape.options['href'] is not None: # Hyperlink fh.write(u'<a xlink:href="{}" target="_parent">\n <text class="{} link" x="{}" y="{}">{}</text></a>\n'.format(shape.options['href'], font_name, x, y, txt)) else: fh.write(u'<text class="{}" x="{}" y="{}">{}</text>\n'.format(font_name, x, y, txt)) elif isinstance(shape, OvalShape): x0, y0, x1, y1 = shape.points xc = (x0 + x1) / 2 yc = (y0 + y1) / 2 rad = (x1 - x0) / 2 attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) fh.write(u'<circle cx="{}" cy="{}" r="{}" {}/>\n'.format(xc, yc, rad, attributes)) elif isinstance(shape, ArcShape): x0, y0, x1, y1 = shape.points xc = (x0 + x1) / 2 yc = (y0 + y1) / 2 rad = (x1 - x0) / 2 start = shape.options['start'] % 360 extent = shape.options['extent'] stop = (start + extent) % 360 if extent < 0: start, stop = stop, start # Swap points so we can rotate CCW # Start and end angles sa = math.radians(start) ea = math.radians(stop) attrs['fill'] = 'none' attributes = ' '.join(['{}="{}"'.format(k,v) for k,v in attrs.iteritems()]) xs = xc + rad * math.cos(sa) ys = yc - rad * math.sin(sa) xe = xc + rad * math.cos(ea) ye = yc - rad * math.sin(ea) fh.write(u'<path d="M{},{} A{},{} 0 0,0 {},{}" {}/>\n'.format(xs,ys, rad,rad, xe,ye, attributes)) class RailCanvas(object): '''This is a clone of the Tk canvas subset used by the original Tcl It implements an abstracted canvas that can render objects to different backends other than just a Tk canvas widget. ''' def __init__(self, text_bbox=cairo_text_bbox): self.text_bbox = text_bbox self.shapes = [] def _get_shapes(self, item=None): # Filter shapes if item is None or item == 'all': shapes = self.shapes else: shapes = [s for s in self.shapes if s.is_tagged(item)] return shapes def create_arc(self, x0, y0, x1, y1, **options): shape = ArcShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_line(self, x0, y0, x1, y1, **options): shape = LineShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_oval(self, x0, y0, x1, y1, **options): shape = OvalShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_rectangle(self, x0, y0, x1, y1, **options): shape = RectShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_bubble(self, x0, y0, x1, y1, **options): shape = BubbleShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_boxbubble(self, x0, y0, x1, y1, **options): shape = BoxBubbleShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_hexbubble(self, x0, y0, x1, y1, **options): shape = HexBubbleShape(x0, y0, x1, y1, options) self.shapes.append(shape) def create_text(self, x0, y0, **options): shape = TextShape(x0, y0, self.text_bbox, options) self.shapes.append(shape) # Add a unique tag to serve as an ID id_tag = 'id' + str(TextShape.text_id) TextShape.text_id += 1 shape.tags.add(id_tag) return id_tag def bbox(self, item=None): bx0 = 0 bx1 = 0 by0 = 0 by1 = 0 boxes = [s.bbox for s in self._get_shapes(item)] boxes = zip(*boxes) if len(boxes) > 0: bx0 = min(boxes[0]) by0 = min(boxes[1]) bx1 = max(boxes[2]) by1 = max(boxes[3]) #print('## BBB', (bx0, by0, bx1, by1), boxes) return (bx0, by0, bx1, by1) def move(self, item, dx, dy): #print('## MOVE 1', item, dx, dy, 'Shapes:', len(self._get_shapes(item))) for s in self._get_shapes(item): s.move(dx, dy) def tag_raise(self, item): to_raise = self._get_shapes(item) for s in to_raise: self.shapes.remove(s) self.shapes.extend(to_raise) def addtag_withtag(self, tag, item): for s in self._get_shapes(item): s.addtag(tag) def dtag(self, item, tag=None): for s in self._get_shapes(item): s.dtag(tag) def draw(self, c): '''Draw all shapes on the canvas''' for s in self.shapes: tk_draw_shape(s, c) def delete(self, item): for s in self._get_shapes(item): self.shapes.remove(s) class RailroadLayout(object): def __init__(self, canvas, style, url_map=None): self.canvas = canvas self.tagcnt = 0 self.style = style if url_map is None: url_map = {} self.url_map = url_map def get_tag(self, prefix='x', suffix=''): self.tagcnt += 1 return '{}{}{}'.format(prefix, self.tagcnt, suffix) def draw_right_turnback(self, tag, x, y0, y1, flow='down'): c = self.canvas s = self.style # Ensure y0 < y1 y0, y1 = (min(y0,y1), max(y0,y1)) #if y0 + 2*s.max_radius < y1: # Two bends #print('## RT:', y1, y0, y1-y0, 5*s.max_radius) if y1 - y0 > 3*s.max_radius: # Two bends xr0 = x - s.max_radius xr1 = x + s.max_radius # Top curve c.create_arc(xr0,y0,xr1,y0+2*s.max_radius, width=s.line_width, start=90, extent=-90, tags=(tag,), style='arc') yr0 = y0 + s.max_radius yr1 = y1 - s.max_radius if abs(yr1-yr0) > s.max_radius*2: # Two line segments with arrow in middle half_y = (yr1 + yr0) / 2 if flow == 'down': c.create_line(xr1,yr0,xr1,half_y, width=s.line_width, tags=(tag,), arrow='last') c.create_line(xr1,half_y,xr1,yr1, width=s.line_width, tags=(tag,)) else: # Up c.create_line(xr1,yr1,xr1,half_y, width=s.line_width, tags=(tag,), arrow='last') c.create_line(xr1,half_y,xr1,yr0, width=s.line_width, tags=(tag,)) else: # No arrow c.create_line(xr1,yr0,xr1,yr1, width=s.line_width, tags=(tag,)) # Bottom curve c.create_arc(xr0,y1-2*s.max_radius,xr1,y1, width=s.line_width, start=0, extent=-90, tags=(tag,), style='arc') else: # Single arc turnback r = (y1 - y0) / 2 x0 = x - r x1 = x + r c.create_arc(x0,y0,x1,y1, width=s.line_width, start=90, extent=-180, tags=(tag,), style='arc') def draw_left_turnback(self, tag, x, y0, y1, flow='up'): c = self.canvas s = self.style # Ensure y0 < y1 y0, y1 = (min(y0,y1), max(y0,y1)) #if y0 + 2*s.max_radius < y1: # Two bends if y1 - y0 > 3*s.max_radius: # Two bends xr0 = x - s.max_radius xr1 = x + s.max_radius # Top curve c.create_arc(xr0,y0,xr1,y0+2*s.max_radius, width=s.line_width, start=90, extent=90, tags=(tag,), style='arc') yr0 = y0 + s.max_radius yr1 = y1 - s.max_radius if abs(yr1-yr0) > s.max_radius*2: half_y = (yr1 + yr0) / 2 if flow == 'down': c.create_line(xr0,yr0,xr0,half_y, width=s.line_width, tags=(tag,), arrow='last') c.create_line(xr0,half_y,xr0,yr1, width=s.line_width, tags=(tag,)) else: # Up c.create_line(xr0,yr1,xr0,half_y, width=s.line_width, tags=(tag,), arrow='last') c.create_line(xr0,half_y,xr0,yr0, width=s.line_width, tags=(tag,)) else: c.create_line(xr0,yr0,xr0,yr1, width=s.line_width, tags=(tag,)) # Bottom curve c.create_arc(xr0,y1-2*s.max_radius,xr1,y1, width=s.line_width, start=180, extent=90, tags=(tag,), style='arc') else: # Single arc turnback r = (y1 - y0) / 2 x0 = x - r x1 = x + r c.create_arc(x0,y0,x1,y1, width=s.line_width, start=90, extent=180, tags=(tag,), style='arc') def format_text(self, txt): s = self.style # Default to first node style node_style = s.node_styles[0] # Check each node pattern for a match for ns in s.node_styles: if re.match(ns.pattern, txt): node_style = ns break # Apply any text transformation for this style if ns.text_mod_func: txt = ns.text_mod_func(txt) return (txt, node_style) def draw_bubble(self, txt): tag = self.get_tag() c = self.canvas s = self.style if txt is None: # Line for skipped options c.create_line(0,0,1,0, width=s.outline_width, tags=(tag,)) return [tag, 1, 0] elif txt == 'bullet': # Small bullet w = s.outline_width r = w+1 c.create_oval(0,-r,2*r,r, width=s.outline_width, tags=(tag,), fill=s.bullet_fill) return [tag, 2*r, 0] else: # Bubble with text inside txt, node_style = self.format_text(txt) font = node_style.font font_name = node_style.name + '_font' fill = node_style.fill text_color = node_style.text_color if txt in self.url_map: href = self.url_map[txt] else: href = None id1 = c.create_text(0,0, anchor='c', text=txt, font=font, font_name=font_name, text_color=text_color, tags=(tag,)) x0, y0, x1, y1 = c.bbox(id1) #print('## TEXT BBOX', x0,y0,x1,y1, txt) h = y1 - y0 + 2 rad = (h+1) // 2 # Round up #rad = h / 2.0 #top = y0 - 2 # KPT: Not sure why "top" is derived from y0 btm = y1 top = btm - 2*rad # fudge = int(3*istoken + len(txt)*1.4) # left = x0 + fudge # right = x1 - fudge left = x0 right = x1 if node_style.shape in ('bubble', 'hex'): left += rad // 2 - 2 right -= rad // 2 - 2 else: # Add fixed padding left -= 5 right += 5 if left > right: # Too mutch fudge: Create a circle from two arcs left = (x0 + x1) / 2 # Left and right both at midpoint of text bbox right = left tag2 = self.get_tag(suffix='-box') tags = [tag, tag2] if node_style.shape == 'bubble': # Rounded bubble c.delete(id1) c.create_bubble(left-rad, top, right+rad, btm, text=txt, text_pos=(x0,y0), font=font, font_name=font_name, text_color=text_color, width=s.outline_width, tags=tags, fill=fill, href=href) elif node_style.shape == 'hex': # Hex bubble c.delete(id1) c.create_hexbubble(left-rad, top, right+rad, btm, text=txt, text_pos=(x0,y0), font=font, font_name=font_name, text_color=text_color, width=s.outline_width, tags=tags, fill=fill, href=href) else: # Box bubble c.delete(id1) c.create_boxbubble(left, top, right, btm, text=txt, text_pos=(x0,y0), font=font, font_name=font_name, text_color=text_color, width=s.outline_width, tags=tags, fill=fill, href=href) x0, y0, x1,y1 = c.bbox(tag2) #print('## BUBBLE BBOX:', x0, y0, x1, y1) width = x1 - x0 c.move(tag, -x0, 2) c.tag_raise(id1) # Bring text above any filled bubbles #print('## BUBBLE EXIT: ({})'.format(txt), width, x0, y0, x1, y1) return [tag, width, 0] def draw_line(self, lx, ltor): '''Draw a series of elements from left to right''' tag = self.get_tag() c = self.canvas s = self.style sep = s.h_sep exx = 0 exy = 0 terms = lx if ltor else reversed(lx) # Reverse so we can draw left to right for term in terms: t, texx, texy = self.draw_diagram(term, ltor) # Draw each element if exx > 0: # Second element onward xn = exx + sep # Add space between elements c.move(t, xn, exy) # Shift last element forward arr = 'last' if ltor else 'first' c.create_line(exx-1, exy, xn, exy, tags=(tag,), width=s.line_width, arrow=arr) # Connecting line (NOTE: -1 fudge) exx = xn + texx # Start at end of this element else: # First element exx = texx # Start at end of this element exy = texy c.addtag_withtag(tag, t) # Retag this element c.dtag(t, t) # Drop old tags if exx == 0: # Nothing drawn, Add a line segment with an arrow in the middle exx = sep * 2 c.create_line(0,0,sep,0, width=s.line_width, tags=(tag,), arrow='last') c.create_line(sep, 0,exx,0, width=s.line_width, tags=(tag,)) exx = sep return [tag, exx, exy] # Exit point def draw_stack(self, indent, lx, ltor): tag = self.get_tag() c = self.canvas s = self.style sep = s.v_sep * 2 btm = 0 n = len(lx) i = 0 next_bypass_y = 0 for term in lx: bypass_y = next_bypass_y if i > 0 and i < n and len(term) > 1 and indent >= 0 and \ (term[0] == 'opt' or term[0] == 'optx'): bypass = 1 term = ['line', term[1:]] else: bypass = 0 next_bypass_y = 0 t, exx, exy = self.draw_diagram(term, ltor) tx0, ty0, tx1, ty1 = c.bbox(t) if i == 0: btm = ty1 exit_y = exy exit_x = exx else: enter_y = btm - ty0 + sep*2 + 2 if bypass: next_bypass_y = enter_y - s.max_radius if indent < 0: # rightstack w = tx1 - tx0 enter_x = exit_x - w + sep*indent ex2 = sep*2 - indent if ex2 > enter_x: enter_x = ex2 else: # stack & indentstack enter_x = sep*2 + indent back_y = btm + sep + 1 if bypass_y > 0: mid_y = (bypass_y + s.max_radius + back_y) / 2 c.create_line(bypass_x, bypass_y, bypass_x, mid_y, \ width=s.line_width, tags=(tag,), arrow='last') c.create_line(bypass_x, mid_y, bypass_x, back_y+s.max_radius, \ width=s.line_width, tags=(tag,)) c.move(t, enter_x, enter_y) e2 = exit_x + sep c.create_line(exit_x, exit_y, e2, exit_y, \ width=s.line_width, tags=(tag,)) self.draw_right_turnback(tag, e2, exit_y, back_y) e3 = enter_x - sep bypass_x = e3 - s.max_radius emid = (e2+e3)/2 c.create_line(e2, back_y, emid, back_y, \ width=s.line_width, tags=(tag,), arrow='last') c.create_line(emid, back_y, e3, back_y, \ width=s.line_width, tags=(tag,)) #r2 = (enter_y - back_y) / 2 # FIXME: unused self.draw_left_turnback(tag, e3, back_y, enter_y, 'down') c.create_line(e3, enter_y, enter_x, enter_y, \ width=s.line_width, tags=(tag,), arrow='last') exit_x = enter_x + exx exit_y = enter_y + exy c.addtag_withtag(tag, t) c.dtag(t, t) btm = c.bbox(tag)[3] i += 1 if bypass: fwd_y = btm + sep + 1 mid_y = (next_bypass_y + s.max_radius + fwd_y) / 2 descender_x = exit_x + s.max_radius c.create_line(bypass_x, next_bypass_y, bypass_x, mid_y, \ width=s.line_width, tags=(tag,), arrow='last') c.create_line(bypass_x, mid_y, bypass_x, fwd_y-s.max_radius, \ width=s.line_width, tags=(tag,)) c.create_arc(bypass_x, fwd_y - 2*s.max_radius, bypass_x + 2*s.max_radius, fwd_y, \ width=s.line_width, start=180, extent=90, tags=(tag,), style='arc') c.create_arc(exit_x - s.max_radius, exit_y, descender_x, exit_y + 2*s.max_radius, \ width=s.line_width, start=90, extent=-90, tags=(tag,), style='arc') c.create_arc(descender_x, fwd_y - 2*s.max_radius, descender_x + 2*s.max_radius, fwd_y, \ width=s.line_width, start=180, extent=90, tags=(tag,), style='arc') exit_x = exit_x + 2*s.max_radius half_x = (exit_x + indent) / 2 c.create_line(bypass_x + s.max_radius, fwd_y, half_x, fwd_y, \ width=s.line_width, tags=(tag,), arrow='last') c.create_line(halfx_, fwd_y, exit_x, fwd_y, \ width=s.line_width, tags=(tag,)) c.create_line(descender_x, exit_y+s.max_radius, descender_x, fwd_y-s.max_radius, \ width=s.line_width, tags=(tag,), arrow='last') exit_y = fwd_y width = c.bbox(tag)[2] return [tag, exit_x, exit_y] def draw_loop(self, forward, back, ltor): tag = self.get_tag() c = self.canvas s = self.style sep = s.h_sep vsep = s.v_sep if isinstance(back, basestring) or back is None: back = [back] if len(back) == 1: if back[0] == ',': # Tight space when loop back is single comma vsep = 0 elif back[0] is None: # Tighten spacing when loop back is just a line vsep /= 2 # Forward section ft, fexx, fexy = self.draw_diagram(forward, ltor) fx0, fy0, fx1, fy1 = c.bbox(ft) fw = fx1 - fx0 # Fwd width # Backward section, turn direction bt, bexx, bexy = self.draw_diagram(back, not ltor) bx0, by0, bx1, by1 = c.bbox(bt) bw = bx1 - bx0 # Back width dy = fy1 - by0 + vsep # Amount to shift backward objects #print('## LOOP:', dy, fy1, by0, vsep) c.move(bt, 0, dy) # Move backward objects up above fwd # Recompute input and exit points biny = dy bexy = dy + bexy by0 = dy + by0 by1 = dy + by1 mxx = 0 if fw > bw: # Forward is longer if fexx < fw and fexx >= bw: # Fwd exit point is left of the right side of fwd dx = (fexx - bw) / 2 # Shift backward objects no further than exit point c.move(bt, dx, 0) bexx = dx + bexx # Add extension lines to each side of backward c.create_line(0,biny,dx,biny, width=s.line_width, tags=(bt,)) c.create_line(bexx,bexy,fexx,bexy, width=s.line_width, tags=(bt,), arrow='first') mxx = fexx else: # Fwd exit is aligned with fwd dx = (fw - bw) / 2 c.move(bt, dx, 0) # Shift backward objects to middle of fwd bexx = dx + bexx arr1 = None if ltor or dx < 2*vsep else 'last' arr2 = None if (not ltor) or dx < 2*vsep else 'first' # Add extension lines to each side of backward c.create_line(0,biny,dx,biny, width=s.line_width, tags=(bt,), arrow=arr1) c.create_line(bexx,bexy,fx1,bexy, width=s.line_width, tags=(bt,), arrow=arr2) mxx = fexx elif bw > fw: # Backward is longer dx = (bw - fw) / 2 c.move(ft, dx, 0) # Shift fwd objects to middle of backward fexx = dx + fexx # Add extension lines to each side of fwd arr1 = 'first' if (not ltor) else 'last' c.create_line(0,0,dx,fexy, width=s.line_width, tags=(ft,), arrow=arr1) c.create_line(fexx,fexy,bx1,fexy, width=s.line_width, tags=(ft,)) mxx = bexx else: # Same length mxx = fexx c.addtag_withtag(tag, bt) # Retag c.addtag_withtag(tag, ft) c.dtag(bt, bt) # Drop old tags c.dtag(ft, ft) c.move(tag, sep, 0) # Make space for left turnback mxx = mxx + sep c.create_line(0,0,sep,0, width=s.line_width, tags=(tag,)) # Feed in line meeting above left turnback rot_cw = ltor left_tb_flow = 'up' if rot_cw else 'down' right_tb_flow = 'down' if rot_cw else 'up' self.draw_left_turnback(tag, sep, 0, biny, left_tb_flow) self.draw_right_turnback(tag, mxx, fexy, bexy, right_tb_flow) #x0, y0, x1, y1 = c.bbox(tag) # Bounds for the entire loop exit_x = mxx + s.max_radius # Add radius of right turnback to get full width c.create_line(mxx,fexy,exit_x,fexy, width=s.line_width, tags=(tag,)) # Feed out line above right turnback return [tag, exit_x, fexy] def draw_toploop(self, forward, back, ltor): tag = self.get_tag() c = self.canvas s = self.style sep = s.v_sep vsep = sep / 2 # Tighten spacing for top loops if isinstance(back, basestring) or back is None: back = [back] ft, fexx, fexy = self.draw_diagram(forward, ltor) fx0, fy0, fx1, fy1 = c.bbox(ft) fw = fx1 - fx0 # Backward section, turn direction bt, bexx, bexy = self.draw_diagram(back, not ltor) bx0, by0, bx1, by1 = c.bbox(bt) bw = bx1 - bx0 dy = -(by1 - fy0 + vsep) #print('## TLOOP:', dy, by1, fy0, vsep) c.move(bt, 0, dy) biny = dy bexy = dy + bexy by0 = dy + by0 by1 = dy + by1 mxx = 0 if fw > bw: # Forward is longer dx = (fw - bw) / 2 c.move(bt, dx, 0) # Shift backward objects to middle of fwd bexx = dx + bexx # Add extension lines to each side of backward arr2 = None if ltor or dx < 2*vsep else 'first' c.create_line(0,biny,dx,biny, width=s.line_width, tags=(bt,)) c.create_line(bexx,bexy,fx1,bexy, width=s.line_width, tags=(bt,), arrow=arr2) mxx = fexx elif bw > fw: # Backward is longer dx = (bw - fw) / 2 c.move(ft, dx, 0) # Shift fwd objects to middle of backward fexx = dx + fexx # Add extension lines to each side of fwd c.create_line(0,0,dx,fexy, width=s.line_width, tags=(ft,)) c.create_line(fexx,fexy,bx1,fexy, width=s.line_width, tags=(ft,)) mxx = bexx else: # Same length mxx = fexx c.addtag_withtag(tag, bt) # Retag c.addtag_withtag(tag, ft) c.dtag(bt, bt) # Drop old tags c.dtag(ft, ft) c.move(tag, sep, 0) # Make space for left turnback mxx = mxx + sep c.create_line(0,0,sep,0, width=s.line_width, tags=(tag,)) # Feed in line meeting below left turnback rot_cw = ltor left_tb_flow = 'up' if rot_cw else 'down' right_tb_flow = 'down' if rot_cw else 'up' self.draw_left_turnback(tag, sep, 0, biny, left_tb_flow) self.draw_right_turnback(tag, mxx, fexy, bexy, right_tb_flow) x0, y0, x1, y1 = c.bbox(tag) c.create_line(mxx,fexy,x1,fexy, width=s.line_width, tags=(tag,)) # Feed out line below right turnback return [tag, x1, fexy] def draw_or(self, lx, ltor): tag = self.get_tag() c = self.canvas s = self.style sep = s.v_sep vsep = sep / 2 n = len(lx) m = {} mxw = 0 for i, term in enumerate(lx): m[i] = mx = self.draw_diagram(term, ltor) tx = mx[0] x0, y0, x1, y1 = c.bbox(tx) w = x1 - x0 if i > 0: w += 20 # Extra space for arrowheads if w > mxw: mxw = w x0 = 0 x1 = sep x2 = sep * 2 xc = mxw / 2 x3 = mxw + x2 x4 = x3 + sep x5 = x4 + sep for i in xrange(len(lx)): t, texx, texy = m[i] tx0, ty0, tx1, ty1 = c.bbox(t) w = tx1 - tx0 dx = (mxw - w) / 2 + x2 if w > 10 and dx > x2 + 10: dx = x2 + 10 c.move(t, dx, 0) texx = texx + dx m[i] = [t, texx, texy] tx0, ty0, tx1, ty1 = c.bbox(t) if i == 0: arr1 = 'last' if ltor and dx > x2 else None arr2 = None if ltor else 'first' c.create_line(0,0,dx,0, width=s.line_width, tags=(tag,), arrow=arr1) c.create_line(texx,texy,x5+1,texy, width=s.line_width, tags=(tag,), arrow=arr2) exy = texy c.create_arc(-sep,0,sep,sep*2, width=s.line_width, start=90, extent=-90, tags=(tag,), style='arc') btm = ty1 else: dy = btm - ty0 + vsep if dy < 2*sep: dy = 2*sep c.move(t, 0, dy) texy = texy + dy if dx > x2: arr1 = 'last' if ltor else None arr2 = 'first' if (not ltor) else None c.create_line(x2,dy,dx,dy, width=s.line_width, tags=(tag,), arrow=arr1) c.create_line(texx,texy,x3,texy, width=s.line_width, tags=(tag,), arrow=arr2) y1 = dy - 2*sep c.create_arc(x1,y1,x1+2*sep,dy, width=s.line_width, start=180, extent=90, style='arc', tags=(tag,)) y2 = texy - 2*sep c.create_arc(x3-sep,y2,x4,texy, width=s.line_width, start=270, extent=90, style='arc', tags=(tag,)) if i == len(lx)-1: c.create_arc(x4,exy,x4+2*sep,exy+2*sep, width=s.line_width, start=180, extent=-90, style='arc', tags=(tag,)) c.create_line(x1,dy-sep,x1,sep, width=s.line_width, tags=(tag,)) c.create_line(x4,texy-sep,x4,exy+sep, width=s.line_width, tags=(tag,)) btm = ty1 + dy c.addtag_withtag(tag, t) c.dtag(t, t) return [tag, x5, exy] def draw_diagram(self, spec, ltor): if isinstance(spec, basestring): spec = [spec] if spec is None: return self.draw_bubble(spec) if len(spec) == 1: return self.draw_bubble(spec[0]) elif len(spec) == 0: return self.draw_bubble(None) else: if spec[0] == 'line': return self.draw_line(spec[1:], ltor) elif spec[0] == 'stack': return self.draw_stack(0, spec[1:], ltor) elif spec[0] == 'indentstack': hsep = self.style.h_sep * spec[1] return self.draw_stack(hsep, spec[2:], ltor) elif spec[0] == 'rightstack': return self.draw_stack(-1, spec[1:], ltor) elif spec[0] == 'loop': return self.draw_loop(spec[1], spec[2], ltor) elif spec[0] == 'toploop': return self.draw_toploop(spec[1], spec[2], ltor) elif spec[0] == 'or': return self.draw_or(spec[1:], ltor) elif spec[0] == 'opt': if len(spec) == 2 and is_listy(spec[1]): args = spec[1] else: args = ['line'] + spec[1:] return self.draw_or([None, args], ltor) elif spec[0] == 'optx': # opt with pass through on bottom if len(spec) == 2 and is_listy(spec[1]): args = spec[1] else: args = ['line'] + spec[1:] return self.draw_or([args, None], ltor) elif spec[0] == 'optloop': # opt with all args in a loop args = spec[1:] return self.draw_or([None, ['loop'] + args], ltor) elif spec[0] == 'tailbranch': # NOTE: The original Tcl had a draw_tail_branch proc that was unused here return self.draw_or(spec[1:], ltor) else: raise ValueError('Unrecognized diagram element: "{}"'.format(spec[0])) return None svg_header = u'''<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created by Syntax-Trax http://kevinpt.github.io/syntax-trax --> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="{}" height="{}" version="1.1"> <style type="text/css"> <![CDATA[ {} .label {{fill:#000; text-anchor:middle; font-size:16pt; font-weight:bold; font-family:Sans;}} .link {{fill: #0D47A1;}} .link:hover {{fill: #0D47A1; text-decoration:underline;}} .link:visited {{fill: #4A148C;}} ]]> </style> <defs> <marker id="arrow" markerWidth="5" markerHeight="4" refX="2.5" refY="2" orient="auto" markerUnits="strokeWidth"> <path d="M0,0 L0.5,2 L0,4 L4.5,2 z" fill="{}" /> </marker> </defs> ''' def render_railroad(spec, title, url_map, out_file, backend, styles, scale, transparent): print('Rendering to {} using {} backend'.format(out_file, backend)) rc = RailCanvas(cairo_text_bbox) layout = RailroadLayout(rc, styles, url_map) layout.draw_diagram(spec, True) if title is not None: # Add title pos = styles.title_pos x0,y0,x1,y1 = rc.bbox('all') tid = rc.create_text(0, 0, anchor='l', text=title, font=styles.title_font, font_name='title_font') tx0, ty0, tx1, ty1 = rc.bbox(tid) h = ty1 - ty0 w = tx1 - tx0 mx = x0 if 'l' in pos else (x1 + x0 - w) / 2 if 'c' in pos else x0 + x1 - w my = (y0 - h - styles.padding) if 't' in pos else (y1 - y0 - styles.padding) rc.move(tid, mx, my) x0,y0,x1,y1 = rc.bbox('all') W = int((x1 - x0 + 2*styles.padding) * scale) H = int((y1 - y0 + 2*styles.padding) * scale) if not styles.arrows: # Remove arrow heads for s in rc.shapes: if 'arrow' in s.options: del s.options['arrow'] if styles.shadow: # Draw shadows first bubs = [copy.deepcopy(s) for s in rc.shapes if isinstance(s, BoxBubbleShape) or isinstance(s, BubbleShape) or isinstance(s, HexBubbleShape)] # Remove all text and offset shadow for s in bubs: del s.options['text'] s.options['fill'] = styles.shadow_fill w = s.options['width'] s.options['width'] = 0 s.move(w+1,w+1) # Put rest of shapes after the shadows bubs.extend(rc.shapes) rc.shapes = bubs if backend == 'svg': # Reposition all shapes in the viewport for s in rc.shapes: s.move(-x0 + styles.padding, -y0 + styles.padding) # Generate CSS for fonts text_color = rgb_to_hex(styles.text_color) css = [] fonts = {} # Collect fonts from common styles for f in [k for k in dir(styles) if k.endswith('_font')]: fonts[f] = (getattr(styles, f), text_color) # Collect node style fonts for ns in styles.node_styles: fonts[ns.name + '_font'] = (ns.font, rgb_to_hex(ns.text_color)) for f, fs in fonts.iteritems(): family, size, weight = fs[0] text_color = fs[1] if weight == 'italic': style = 'italic' weight = 'normal' else: style = 'normal' css.append('''.{} {{fill:{}; text-anchor:middle; font-family:{}; font-size:{}pt; font-weight:{}; font-style:{};}}'''.format(f, text_color, family, size, weight, style)) font_styles = '\n'.join(css) line_color = rgb_to_hex(styles.line_color) with io.open(out_file, 'w', encoding='utf-8') as fh: fh.write(svg_header.format(W,H, font_styles, line_color)) if not transparent: fh.write(u'<rect width="100%" height="100%" fill="white"/>') for s in rc.shapes: svg_draw_shape(s, fh, styles) fh.write(u'</svg>') else: # Cairo backend ext = os.path.splitext(out_file)[1].lower() if ext == '.svg': surf = cairo.SVGSurface(out_file, W, H) elif ext == '.pdf': surf = cairo.PDFSurface(out_file, W, H) elif ext in ('.ps', '.eps'): surf = cairo.PSSurface(out_file, W, H) if ext == '.eps': surf.set_eps(True) else: # Bitmap surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, W, H) ctx = cairo.Context(surf) if not transparent: # Fill background ctx.rectangle(0,0, W,H) ctx.set_source_rgba(1.0,1.0,1.0) ctx.fill() ctx.scale(scale, scale) ctx.translate(-x0 + styles.padding, -y0 + styles.padding) for s in rc.shapes: cairo_draw_shape(s, ctx, styles) if ext in ('.svg', '.pdf', '.ps', '.eps'): surf.show_page() else: surf.write_to_png(out_file) def line(*args): return ['line'] + list(args) def loop(fwd, back): return ['loop', fwd, back] def toploop(fwd, back): return ['toploop', fwd, back] def choice(*args): return ['or'] + list(args) def is_listy(v): return isinstance(v, collections.Sequence) and not isinstance(v, basestring) def opt(*args): largs = list(args) return ['opt'] + largs def optx(*args): return ['optx'] + list(args) def optloop(fwd, back): return ['optloop', fwd, [back]] def stack(*args): return ['stack'] + list(args) def rightstack(*args): return ['rightstack'] + list(args) def indentstack(indent, *args): return ['indentstack', indent] + list(args) url_map_re = re.compile(r'^\s*url_map\s*=\s*') def parse_spec_file(fname): # Read input diagram with io.open(fname, 'r', encoding='utf-8') as fh: spec_lines = fh.readlines() map_line = -1 # Split off any url_map for i,l in enumerate(spec_lines): if url_map_re.match(l): map_line = i break if map_line >= 0: spec = ''.join(spec_lines[:map_line]) url_map = ''.join(spec_lines[map_line:]) # Strip off assignment url_map = url_map_re.sub('', url_map) else: # No URL map spec = ''.join(spec_lines) url_map = '{}' # Parse the spec into an object spec = eval(spec) # FIXME: Unsafe # Add start and end bullets spec = ['line', 'bullet', spec, 'bullet'] url_map = ast.literal_eval(url_map) return spec, url_map def dump_style_ini(ini_file): keys= ('line_width', 'outline_width', 'padding', 'line_color', 'max_radius', 'h_sep', 'v_sep', 'arrows', 'title_pos', 'bullet_fill', 'text_color', 'shadow', 'shadow_fill', 'title_font') defaults = DrawStyle() if os.path.exists(ini_file): print('Ini file "{}" exists'.format(ini_file)) return print('Creating ini with default styles in "{}"'.format(ini_file)) with open(ini_file, 'w') as fh: fh.write(str(defaults)) for ns in defaults.node_styles: fh.write('\n') fh.write(str(ns)) def parse_args(): parser = argparse.ArgumentParser(description='Railroad diagram generator') parser.add_argument('-i', '--input', dest='input', action='store', help='Diagram spec file') parser.add_argument('-o', '--output', dest='output', action='store', help='Output file') parser.add_argument('-s', '--style', dest='styles', action='store', default='syntrax.ini', help='Style config file') parser.add_argument('--title', dest='title', action='store', help='Diagram title') parser.add_argument('-t', '--transparent', dest='transparent', action='store_true', default=False, help='Transparent background') parser.add_argument('--scale', dest='scale', action='store', default='1', help='Scale image') parser.add_argument('-v', '--version', dest='version', action='store_true', default=False, help='Syntrax version') parser.add_argument('--get-style', dest='get_style', action='store_true', default=False, help='Create default style .ini') args, unparsed = parser.parse_known_args() if args.version: print('Syntrax {}'.format(__version__)) sys.exit(0) if args.get_style: dump_style_ini('syntrax.ini') sys.exit(0) # Allow file to be passed in without -i if args.input is None and len(unparsed) > 0: args.input = unparsed[0] if args.input is None: print('Error: input file is required') sys.exit(1) if args.output is None: # Default to png args.output = os.path.splitext(args.input)[0] + '.png' if args.output.lower() in ('png', 'svg', 'pdf', 'ps', 'eps'): args.output = os.path.splitext(args.input)[0] + '.' + args.output.lower() args.scale = float(args.scale) return args def main(): args = parse_args() # Process styles styles = parse_style_config(args.styles) spec, url_map = parse_spec_file(args.input) #print('## spec', spec) # Force SVG backend for SVG output backend = 'cairo' if os.path.splitext(args.output)[1].lower() == '.svg': backend = 'svg' #title = 'JSON syntax number' #title = None render_railroad(spec, args.title, url_map, args.output, backend, styles, args.scale, args.transparent) if __name__ == '__main__': main()