1 #!/usr/bin/env python 2 3 """ 4 Graphviz utilities. 5 6 Copyright (C) 2018, 2019 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from moinformat.errors import ProcessingError 23 from moinformat.resources import __file__ as resources_file 24 from os.path import exists, join, split 25 from StringIO import StringIO 26 from subprocess import Popen, PIPE 27 from xml.sax.saxutils import XMLGenerator 28 import gzip 29 import sha 30 import xml.sax 31 32 # Configurable paths and locations. 33 34 GRAPHVIZ_PATH = "/usr/bin" 35 XSLT_PROCESSOR = "/usr/bin/xsltproc" 36 37 # Graphviz "filter" programs performing layout. 38 39 FILTERS = ['circo', 'dot', 'fdp', 'neato', 'twopi'] 40 41 # Supported output formats. 42 43 IMAGE_FORMATS = ['png', 'gif'] 44 SVG_FORMATS = ['svg', 'svgz'] 45 46 OUTPUT_FORMATS = IMAGE_FORMATS + SVG_FORMATS + \ 47 ['dia', 'fig', 'hpgl', 'imap', 'mif', 'pcl', 'ps'] 48 49 # XSL transformations for SVG output. 50 51 RESOURCES_PATH = split(resources_file)[0] 52 53 TRANSFORMS = { 54 "fixlinks" : (join(RESOURCES_PATH, "fixlinks.xsl"), ["document_index"]), 55 "notugly" : (join(RESOURCES_PATH, "notugly.xsl"), []), 56 } 57 58 59 60 # Utility functions. 61 62 def encode(s, encoding): 63 64 "Encode 's' using 'encoding' if Unicode." 65 66 if isinstance(s, unicode): 67 return s.encode(encoding) 68 else: 69 return s 70 71 class Parser(xml.sax.handler.ContentHandler): 72 73 "Common XML parsing functionality." 74 75 def parse(self, f): 76 77 "Parse content from the file object 'f' using reasonable defaults." 78 79 try: 80 parser = xml.sax.make_parser() 81 parser.setContentHandler(self) 82 parser.setErrorHandler(xml.sax.handler.ErrorHandler()) 83 parser.setFeature(xml.sax.handler.feature_external_ges, 0) 84 parser.parse(f) 85 finally: 86 f.close() 87 88 def parse_data(self, data): 89 90 "Parse the given 'data'." 91 92 f = StringIO(data) 93 try: 94 self.parse(f) 95 finally: 96 f.close() 97 98 class MetadataParser(Parser): 99 100 "Parse metadata from the svg element." 101 102 def __init__(self): 103 self.attrs = {} 104 105 def startElement(self, name, attrs): 106 if name == self.tagname: 107 self.attrs = dict(attrs) 108 109 def get_metadata(self, data, tagname): 110 111 "Process 'data', returning attributes from 'tagname'." 112 113 self.tagname = tagname 114 self.parse_data(data) 115 return self.attrs 116 117 class DocumentSelector(XMLGenerator, Parser): 118 119 "Parse a document and obtain the serialisation of the document node." 120 121 def startDocument(self): 122 pass 123 124 def get_output_identifier(text): 125 126 "Return an output identifier for the given 'text'." 127 128 return sha.new(encode(text, 'utf-8')).hexdigest() 129 130 def get_program(filter): 131 132 "Return the program for the given 'filter'." 133 134 if not filter in FILTERS: 135 return None 136 else: 137 return join(GRAPHVIZ_PATH, filter) 138 139 def transform_output(process, format, transforms, params): 140 141 """ 142 Transform the output from 'process' as 'format' using 'transforms' and 143 'params'. 144 """ 145 146 # No transformation can occur if the processor is missing. 147 148 if not exists(XSLT_PROCESSOR): 149 return process 150 151 # Chain transformation processors, each accepting the output of the 152 # preceding one, with the first accepting the initial Graphviz output. 153 154 for transform in transforms: 155 stylesheet, stylesheet_params = TRANSFORMS.get(transform) 156 157 # Ignore unrecognised or missing stylesheets. 158 159 if not stylesheet or not exists(stylesheet): 160 continue 161 162 # Build the command parameters. 163 164 command_params = [] 165 166 for param in stylesheet_params: 167 value = params.get(param) 168 if value: 169 command_params += ["--stringparam", param, value] 170 171 # Invoke the processor, indicating standard input as the source 172 # document. 173 # Example: /usr/bin/xsltproc notugly.xsl - 174 175 process = Popen( 176 [XSLT_PROCESSOR] + command_params + [stylesheet, "-"], 177 shell=False, 178 stdin=process.stdout, 179 stdout=PIPE, 180 stderr=PIPE, 181 close_fds=True) 182 183 return process 184 185 def writefile(s, filename, compressed=False): 186 187 "Write 's' to the file having 'filename'." 188 189 if compressed: 190 f = gzip.open(filename, "w") 191 else: 192 f = open(filename, "w") 193 194 try: 195 f.write(s) 196 finally: 197 f.close() 198 199 200 201 # Classes for interacting with Graphviz. 202 203 class GraphvizError(ProcessingError): 204 205 "An error produced when using Graphviz." 206 207 pass 208 209 class Graphviz: 210 211 "A Graphviz configuration for single or repeated invocation." 212 213 def __init__(self, filter, text): 214 215 """ 216 Employ the given 'filter' to produce a graph from the given 'text'. 217 """ 218 219 self.filter = filter 220 self.text = text 221 222 def call(self, format, transforms=None, filename=None, params=None): 223 224 """ 225 Invoke Graphviz to produce output in the given 'format'. Any 226 'transforms' are used to transform the output, if appropriate. Any 227 given 'filename' is used to write to a file. 228 229 Any 'params' are used to parameterise any transformations. 230 """ 231 232 program = get_program(self.filter) 233 234 # Generate uncompressed SVG for later compression. 235 236 graphviz_format = format == "svgz" and "svg" or format 237 238 # Indicate a filename for direct output for non-SVG formats. 239 240 svg = format in SVG_FORMATS 241 options = filename and not svg and ["-o", filename] or [] 242 243 # Invoke the layout program, with the text to be provided on its 244 # standard input. 245 # Example: /usr/bin/dot -Tsvg -o filename 246 247 start = end = Popen( 248 [program, '-T%s' % graphviz_format] + options, 249 shell=False, 250 stdin=PIPE, 251 stdout=PIPE, 252 stderr=PIPE) 253 254 # Chain the invocation to transformations, if appropriate. 255 256 if svg and transforms: 257 end = transform_output(start, format, transforms, params) 258 259 # Send the graph to the filter. 260 261 start.stdin.write(encode(self.text, 'utf-8')) 262 263 if end is not start: 264 start.stdin.close() 265 266 # Obtain the eventual output. 267 268 (self.output, errors) = end.communicate() 269 270 # Test for errors. 271 272 if end.wait() != 0: 273 raise GraphvizError, errors 274 275 # Obtain any metadata. 276 277 if svg: 278 parser = MetadataParser() 279 self.metadata = parser.get_metadata(self.output, "svg") 280 elif format == "cmapx": 281 parser = MetadataParser() 282 self.metadata = parser.get_metadata(self.output, "map") 283 else: 284 self.metadata = {} 285 286 # Write the file separately, if requested. 287 288 if svg and filename: 289 writefile(self.get_output(), filename, format == "svgz") 290 291 def get_metadata(self): 292 return self.metadata 293 294 def get_output(self): 295 return self.output 296 297 def get_inline_output(self): 298 299 """ 300 Return a Unicode string containing the document element, excluding XML 301 boilerplate. 302 """ 303 304 f = StringIO() 305 parser = DocumentSelector(f, "utf-8") 306 307 try: 308 parser.parse_data(self.output) 309 return unicode(f.getvalue(), "utf-8") 310 finally: 311 f.close() 312 313 # vim: tabstop=4 expandtab shiftwidth=4