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