MoinLight

Annotated moinformat/utils/graphviz.py

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