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