1 # -*- coding: utf-8 -*- 2 """ 3 MoinMoin - Graphviz Parser 4 Based loosely on GNUPLOT parser by MoinMoin:KwonChanYoung 5 6 @copyright: 2008 Wayne Tucker 7 @copyright: 2011, 2012, 2013, 2014 Paul Boddie <paul@boddie.org.uk> 8 @copyright: 2012 Frederick Capovilla (Lib?o) <fcapovilla@live.ca> 9 @license: GNU GPL, see COPYING for details. 10 """ 11 12 __version__ = "0.2.4" 13 14 # Change this to the directory that the Graphviz binaries (dot, neato, etc.) 15 # are installed in. 16 17 BINARY_PATH = '/usr/bin/' 18 XSLT_PROCESSOR = "/usr/bin/xsltproc" 19 DIAGRAM_TOOLS_PATH = '/usr/local/share/diagram-tools' 20 21 from os.path import join, exists 22 from StringIO import StringIO 23 import os 24 import subprocess 25 import sha 26 import re 27 28 from MoinMoin import config 29 from MoinMoin.action import AttachFile 30 from MoinMoin import log 31 from MoinMoin import wikiutil 32 33 logging = log.getLogger(__name__) 34 35 class GraphVizError(RuntimeError): 36 pass 37 38 Dependencies = ["pages"] 39 40 class Parser: 41 42 "Uses the Graphviz programs to create a visualization of a graph." 43 44 extensions = [] 45 Dependencies = Dependencies 46 47 FILTERS = ['dot', 'neato', 'twopi', 'circo', 'fdp'] 48 IMAGE_FORMATS = ['png', 'gif'] 49 SVG_FORMATS = ['svg', 'svgz'] 50 OUTPUT_FORMATS = IMAGE_FORMATS + SVG_FORMATS + \ 51 ['ps', 'fig', 'mif', 'hpgl', 'pcl', 'dia', 'imap'] 52 TRANSFORMS = { 53 "notugly" : join(DIAGRAM_TOOLS_PATH, "notugly.xsl"), 54 } 55 56 attach_regexp = re.compile( 57 r"graphviz_" 58 r"(?P<digest>.*?)" 59 r"(?:" # begin optional section 60 r"_(?P<width>.*?)_(?P<height>.*?)" # dimensions 61 r")?" # end optional section 62 r"\.(?P<format>.*)" 63 r"$") 64 65 attr_regexp = re.compile( 66 r"(?P<attr>width|height)" 67 r"\s*=\s*" 68 r"""(?P<quote>['"])""" # start quote 69 r"(?P<value>.*?)" 70 r"""(?P=quote)""", # matching quote 71 re.UNICODE) 72 73 def __init__(self, raw, request, **kw): 74 self.raw = raw 75 self.request = request 76 77 def format(self, formatter): 78 79 "Using the 'formatter', return the formatted page output." 80 81 request = self.request 82 page = request.page 83 _ = request.getText 84 85 # NOTE: Flushing the request is not supported in 1.9. 86 87 if hasattr(request, "flush"): 88 request.flush() # to identify error text 89 90 filter = self.FILTERS[0] 91 format = 'png' 92 cmapx = None 93 width = None 94 height = None 95 transforms = [] 96 97 raw_lines = self.raw.splitlines() 98 for l in raw_lines: 99 if not l[0:2] == '//': 100 break 101 102 parts = l[2:].split("=") 103 directive = parts[0] 104 value = "=".join(parts[1:]) 105 106 if directive == 'filter': 107 filter = value.lower() 108 if filter not in self.FILTERS: 109 logging.warn('unknown filter %s' % filter) 110 111 elif directive == 'format': 112 value = value.lower() 113 if value in self.OUTPUT_FORMATS: 114 format = value 115 116 elif directive == 'cmapx': 117 cmapx = wikiutil.escape(value) 118 119 elif directive == 'transform': 120 transform = value.lower() 121 if not self.TRANSFORMS.has_key(transform): 122 logging.warn('unknown transform %s' % transform) 123 else: 124 transforms.append(transform) 125 126 if not format in self.OUTPUT_FORMATS: 127 raise NotImplementedError, "only formats %s are currently supported" % \ 128 self.OUTPUT_FORMATS 129 130 if cmapx and not format in self.IMAGE_FORMATS: 131 logging.warn('format %s is incompatible with cmapx option' % format) 132 cmapx = None 133 134 digest = sha.new(self.raw.encode('utf-8')).hexdigest() 135 136 # Make sure that an attachments directory exists and that old graphs are 137 # deleted. 138 139 self.attach_dir = AttachFile.getAttachDir(request, page.page_name, create=1) 140 self.delete_old_graphs(formatter) 141 142 # Find the details of the graph, rendering a new graph if necessary. 143 144 attrs = self.find_graph(digest, format) 145 if not attrs: 146 attrs = self.graphviz(filter, self.raw, digest, format, transforms) 147 148 chart = self.get_chartname(digest, format, attrs) 149 url = AttachFile.getAttachUrl(page.page_name, chart, request) 150 151 # Images are displayed using the HTML "img" element (or equivalent) 152 # and may provide an imagemap. 153 154 if format in self.IMAGE_FORMATS: 155 if cmapx: 156 request.write('\n' + self.graphviz(filter, self.raw, digest, "cmapx", transforms) + '\n') 157 request.write(formatter.image(src="%s" % url, usemap="#%s" % cmapx, **self.get_format_attrs(attrs))) 158 else: 159 request.write(formatter.image(src="%s" % url, alt="graphviz image", **self.get_format_attrs(attrs))) 160 161 # Other objects are embedded using the HTML "object" element (or 162 # equivalent). 163 164 else: 165 request.write(formatter.transclusion(1, data=url, **self.get_format_attrs(attrs))) 166 request.write(formatter.text(_("graphviz image"))) 167 request.write(formatter.transclusion(0)) 168 169 def find_graph(self, digest, format): 170 171 "Find an existing graph using 'digest' and 'format'." 172 173 attach_files = AttachFile._get_files(self.request, self.request.page.page_name) 174 175 for chart in attach_files: 176 match = self.attach_regexp.match(chart) 177 178 if match and \ 179 match.group("digest") == digest and \ 180 match.group("format") == format: 181 182 return match.groupdict() 183 184 return None 185 186 def get_chartname(self, digest, format, attrs=None): 187 188 "Return the chart name for the 'digest', 'format' and 'attrs'." 189 190 wh = self.get_dimensions(attrs) 191 if wh: 192 dimensions = "_%s_%s" % wh 193 else: 194 dimensions = "" 195 return "graphviz_%s%s.%s" % (digest, dimensions, format) 196 197 def delete_old_graphs(self, formatter): 198 199 "Using the 'formatter' for page information, delete old graphs." 200 201 page_info = formatter.page.lastEditInfo() 202 try: 203 page_date = page_info['time'] 204 except KeyError, ex: 205 return 206 207 attach_files = AttachFile._get_files(self.request, self.request.page.page_name) 208 209 for chart in attach_files: 210 match = self.attach_regexp.match(chart) 211 212 if match and match.group("format") in self.OUTPUT_FORMATS: 213 fullpath = join(self.attach_dir, chart).encode(config.charset) 214 st = os.stat(fullpath) 215 chart_date = self.request.user.getFormattedDateTime(st.st_mtime) 216 if chart_date < page_date: 217 os.remove(fullpath) 218 219 def graphviz(self, filter, graph_def, digest, format, transforms): 220 221 """ 222 Using the 'filter' with the given 'graph_def' (and 'digest'), generate 223 output in the given 'format'. 224 """ 225 226 need_output = format in ("cmapx", "svg") 227 program = join(BINARY_PATH, filter) 228 229 # Either write the output straight to a file. 230 231 if not need_output: 232 chart = self.get_chartname(digest, format) 233 filename = join(self.attach_dir, chart).encode(config.charset) 234 options = ['-o%s' % filename] 235 236 # Or intercept the output. 237 238 else: 239 options = [] 240 241 start = p = subprocess.Popen( 242 [program, '-T%s' % format] + options, 243 shell=False, 244 stdin=subprocess.PIPE, 245 stdout=subprocess.PIPE, 246 stderr=subprocess.PIPE) 247 248 if format == "svg" and transforms: 249 p = self.transform_output(p, transforms) 250 251 start.stdin.write(graph_def.encode('utf-8')) 252 253 if p is not start: 254 start.stdin.close() 255 256 (stdoutdata, stderrdata) = p.communicate() 257 258 # Graph data always goes via standard output so that we can extract the 259 # width and height if possible. 260 261 if need_output: 262 output, attrs = self.process_output(StringIO(stdoutdata), format, transforms) 263 else: 264 output, attrs = None, {} 265 266 # Test for errors. 267 268 errors = stderrdata 269 270 if len(errors) > 0: 271 logging.warn(errors) 272 273 if p.wait() != 0: 274 raise GraphVizError, errors 275 276 # Return the output for imagemaps. 277 278 if format == "cmapx": 279 return output 280 281 # Copy to a file, if necessary. 282 283 elif need_output: 284 chart = self.get_chartname(digest, format, attrs) 285 filename = join(self.attach_dir, chart).encode(config.charset) 286 287 f = open(filename, "wb") 288 try: 289 f.write(output) 290 finally: 291 f.close() 292 293 # Return the dimensions, if defined. 294 295 return attrs 296 297 def transform_output(self, process, transforms): 298 299 # First, transform the output, if requested. 300 301 if exists(XSLT_PROCESSOR): 302 for transform in transforms: 303 process = subprocess.Popen( 304 [XSLT_PROCESSOR, self.TRANSFORMS[transform], "-"], 305 shell=False, 306 stdin=process.stdout, 307 stdout=subprocess.PIPE, 308 stderr=subprocess.PIPE, 309 close_fds=True) 310 else: 311 logging.warn('XSLT processor not found at %s' % XSLT_PROCESSOR) 312 313 return process 314 315 def process_output(self, output, format, transforms): 316 317 "Process graph 'output' in the given 'format'." 318 319 # Return the raw output if SVG is not being produced. 320 321 if format != "svg": 322 return output.read(), {} 323 324 # Otherwise, return the processed SVG output. 325 326 processed = [] 327 found = False 328 attrs = {} 329 330 for line in output.readlines(): 331 if not found and line.startswith("<svg "): 332 for match in self.attr_regexp.finditer(line): 333 attrs[match.group("attr")] = match.group("value") 334 found = True 335 processed.append(line) 336 337 return "".join(processed), attrs 338 339 def get_dimensions(self, attrs): 340 341 "Return a (width, height) tuple using the 'attrs' dictionary." 342 343 if attrs and attrs.get("width") and attrs.get("height"): 344 return attrs["width"], attrs["height"] 345 else: 346 return None 347 348 def get_format_attrs(self, attrs): 349 350 "Return a dictionary based on 'attrs' with only formatting attributes." 351 352 dattrs = {} 353 for key in ("width", "height"): 354 if attrs.has_key(key): 355 dattrs[key] = attrs[key] 356 return dattrs 357 358 # vim: tabstop=4 expandtab shiftwidth=4