MoinLight

Annotated moinformat/utils/graphviz.py

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