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 os.path import exists, join 24 from StringIO import StringIO 25 from subprocess import Popen, PIPE 26 from xml.sax.saxutils import XMLGenerator 27 import gzip 28 import sha 29 import xml.sax 30 31 # Configurable paths and locations. 32 33 DIAGRAM_TOOLS_PATH = "/home/paulb/Software/Graphical/diagram-tools" 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 TRANSFORMS = { 52 "notugly" : join(DIAGRAM_TOOLS_PATH, "notugly.xsl"), 53 } 54 55 56 57 # Utility functions. 58 59 def encode(s, encoding): 60 61 "Encode 's' using 'encoding' if Unicode." 62 63 if isinstance(s, unicode): 64 return s.encode(encoding) 65 else: 66 return s 67 68 class Parser(xml.sax.handler.ContentHandler): 69 70 "Common XML parsing functionality." 71 72 def parse(self, f): 73 74 "Parse content from the file object 'f' using reasonable defaults." 75 76 try: 77 parser = xml.sax.make_parser() 78 parser.setContentHandler(self) 79 parser.setErrorHandler(xml.sax.handler.ErrorHandler()) 80 parser.setFeature(xml.sax.handler.feature_external_ges, 0) 81 parser.parse(f) 82 finally: 83 f.close() 84 85 def parse_data(self, data): 86 87 "Parse the given 'data'." 88 89 f = StringIO(data) 90 try: 91 self.parse(f) 92 finally: 93 f.close() 94 95 class MetadataParser(Parser): 96 97 "Parse metadata from the svg element." 98 99 def __init__(self): 100 self.attrs = {} 101 102 def startElement(self, name, attrs): 103 if name == self.tagname: 104 self.attrs = dict(attrs) 105 106 def get_metadata(self, data, tagname): 107 108 "Process 'data', returning attributes from 'tagname'." 109 110 self.tagname = tagname 111 self.parse_data(data) 112 return self.attrs 113 114 class DocumentSelector(XMLGenerator, Parser): 115 116 "Parse a document and obtain the serialisation of the document node." 117 118 def startDocument(self): 119 pass 120 121 def get_output_identifier(text): 122 123 "Return an output identifier for the given 'text'." 124 125 return sha.new(encode(text, 'utf-8')).hexdigest() 126 127 def get_program(filter): 128 129 "Return the program for the given 'filter'." 130 131 if not filter in FILTERS: 132 return None 133 else: 134 return join(GRAPHVIZ_PATH, filter) 135 136 def transform_output(process, format, transforms): 137 138 "Transform the output from 'process' as 'format' using 'transforms'." 139 140 # No transformation can occur if the processor is missing. 141 142 if not exists(XSLT_PROCESSOR): 143 return process 144 145 # Chain transformation processors, each accepting the output of the 146 # preceding one, with the first accepting the initial Graphviz output. 147 148 for transform in transforms: 149 stylesheet = TRANSFORMS.get(transform) 150 151 # Ignore unrecognised or missing stylesheets. 152 153 if not stylesheet or not exists(stylesheet): 154 continue 155 156 # Invoke the processor, indicating standard input as the source 157 # document. 158 # Example: /usr/bin/dot /usr/local/share/diagram-tools/notugly.xsl - 159 160 process = Popen( 161 [XSLT_PROCESSOR, stylesheet, "-"], 162 shell=False, 163 stdin=process.stdout, 164 stdout=PIPE, 165 stderr=PIPE, 166 close_fds=True) 167 168 return process 169 170 def writefile(s, filename, compressed=False): 171 172 "Write 's' to the file having 'filename'." 173 174 if compressed: 175 f = gzip.open(filename, "w") 176 else: 177 f = open(filename, "w") 178 179 try: 180 f.write(s) 181 finally: 182 f.close() 183 184 185 186 # Classes for interacting with Graphviz. 187 188 class GraphvizError(ProcessingError): 189 190 "An error produced when using Graphviz." 191 192 pass 193 194 class Graphviz: 195 196 "A Graphviz configuration for single or repeated invocation." 197 198 def __init__(self, filter, text): 199 200 """ 201 Employ the given 'filter' to produce a graph from the given 'text'. 202 """ 203 204 self.filter = filter 205 self.text = text 206 207 def call(self, format, transforms=None, filename=None): 208 209 """ 210 Invoke Graphviz to produce output in the given 'format'. Any 211 'transforms' are used to transform the output, if appropriate. Any 212 given 'filename' is used to write to a file. 213 """ 214 215 program = get_program(self.filter) 216 217 # Generate uncompressed SVG for later compression. 218 219 graphviz_format = format == "svgz" and "svg" or format 220 221 # Indicate a filename for direct output for non-SVG formats. 222 223 svg = format in SVG_FORMATS 224 options = filename and not svg and ["-o", filename] or [] 225 226 # Invoke the layout program, with the text to be provided on its 227 # standard input. 228 # Example: /usr/bin/dot -Tsvg -o filename 229 230 start = end = Popen( 231 [program, '-T%s' % graphviz_format] + options, 232 shell=False, 233 stdin=PIPE, 234 stdout=PIPE, 235 stderr=PIPE) 236 237 # Chain the invocation to transformations, if appropriate. 238 239 if svg and transforms: 240 end = transform_output(start, format, transforms) 241 242 # Send the graph to the filter. 243 244 start.stdin.write(encode(self.text, 'utf-8')) 245 246 if end is not start: 247 start.stdin.close() 248 249 # Obtain the eventual output. 250 251 (self.output, errors) = end.communicate() 252 253 # Test for errors. 254 255 if end.wait() != 0: 256 raise GraphvizError, errors 257 258 # Obtain any metadata. 259 260 if svg: 261 parser = MetadataParser() 262 self.metadata = parser.get_metadata(self.output, "svg") 263 elif format == "cmapx": 264 parser = MetadataParser() 265 self.metadata = parser.get_metadata(self.output, "map") 266 else: 267 self.metadata = {} 268 269 # Write the file separately, if requested. 270 271 if svg and filename: 272 writefile(self.get_output(), filename, format == "svgz") 273 274 def get_metadata(self): 275 return self.metadata 276 277 def get_output(self): 278 return self.output 279 280 def get_inline_output(self): 281 282 """ 283 Return a string containing the document element, excluding XML 284 boilerplate. 285 """ 286 287 f = StringIO() 288 parser = DocumentSelector(f, "utf-8") 289 290 try: 291 parser.parse_data(self.output) 292 return f.getvalue() 293 finally: 294 f.close() 295 296 # vim: tabstop=4 expandtab shiftwidth=4