File size: 17,644 Bytes
b7731cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# Copyright 2013 Leighton Pritchard.  All rights reserved.
#
# This file is part of the Biopython distribution and governed by your
# choice of the "Biopython License Agreement" or the "BSD 3-Clause License".
# Please see the LICENSE file that should have been included as part of this
# package.
"""Classes and functions to visualise a KGML Pathway Map.

The KGML definition is as of release KGML v0.7.1
(http://www.kegg.jp/kegg/xml/docs/)

Classes:
"""


import os
import tempfile
from io import BytesIO

try:
    from reportlab.lib import colors
    from reportlab.pdfgen import canvas
except ImportError:
    from Bio import MissingPythonDependencyError

    raise MissingPythonDependencyError(
        "Install reportlab if you want to use KGML_vis."
    ) from None

try:
    from PIL import Image
except ImportError:
    from Bio import MissingPythonDependencyError

    raise MissingPythonDependencyError(
        "Install pillow if you want to use KGML_vis."
    ) from None

from urllib.request import urlopen

from Bio.KEGG.KGML.KGML_pathway import Pathway


def darken(color, factor=0.7):
    """Return darkened color as a ReportLab RGB color.

    Take a passed color and returns a Reportlab color that is darker by the
    factor indicated in the parameter.
    """
    newcol = color_to_reportlab(color)
    for a in ["red", "green", "blue"]:
        setattr(newcol, a, factor * getattr(newcol, a))
    return newcol


def color_to_reportlab(color):
    """Return the passed color in Reportlab Color format.

    We allow colors to be specified as hex values, tuples, or Reportlab Color
    objects, and with or without an alpha channel. This function acts as a
    Rosetta stone for conversion of those formats to a Reportlab Color
    object, with alpha value.

    Any other color specification is returned directly
    """
    # Reportlab Color objects are in the format we want already
    if isinstance(color, colors.Color):
        return color
    elif isinstance(color, str):  # String implies hex color
        if color.startswith("0x"):  # Standardise to octothorpe
            color.replace("0x", "#")
        if len(color) == 7:
            return colors.HexColor(color)
        else:
            try:
                return colors.HexColor(color, hasAlpha=True)
            except TypeError:  # Catch pre-2.7 Reportlab
                raise RuntimeError(
                    "Your reportlab seems to be too old, try 2.7 onwards"
                ) from None
    elif isinstance(color, tuple):  # Tuple implies RGB(alpha) tuple
        return colors.Color(*color)
    return color


def get_temp_imagefilename(url):
    """Return filename of temporary file containing downloaded image.

    Create a new temporary file to hold the image file at the passed URL
    and return the filename.
    """
    img = urlopen(url).read()
    im = Image.open(BytesIO(img))
    # im.transpose(Image.FLIP_TOP_BOTTOM)
    f = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
    fname = f.name
    f.close()
    im.save(fname, "PNG")
    return fname


class KGMLCanvas:
    """Reportlab Canvas-based representation of a KGML pathway map."""

    def __init__(
        self,
        pathway,
        import_imagemap=False,
        label_compounds=True,
        label_orthologs=True,
        label_reaction_entries=True,
        label_maps=True,
        show_maps=False,
        fontname="Helvetica",
        fontsize=6,
        draw_relations=True,
        show_orthologs=True,
        show_compounds=True,
        show_genes=True,
        show_reaction_entries=True,
        margins=(0.02, 0.02),
    ):
        """Initialize the class."""
        self.pathway = pathway
        self.show_maps = show_maps
        self.show_orthologs = show_orthologs
        self.show_compounds = show_compounds
        self.show_genes = show_genes
        self.show_reaction_entries = show_reaction_entries
        self.label_compounds = label_compounds
        self.label_orthologs = label_orthologs
        self.label_reaction_entries = label_reaction_entries
        self.label_maps = label_maps
        self.fontname = fontname
        self.fontsize = fontsize
        self.draw_relations = draw_relations
        self.non_reactant_transparency = 0.3
        self.import_imagemap = import_imagemap  # Import the map .png from URL
        # percentage of canvas that will be margin in on either side in the
        # X and Y directions
        self.margins = margins

    def draw(self, filename):
        """Add the map elements to the drawing."""
        # Instantiate the drawing, first
        # size x_max, y_max for now - we can add margins, later
        if self.import_imagemap:
            # We're drawing directly on the image, so we set the canvas to the
            # same size as the image
            if os.path.isfile(self.pathway.image):
                imfilename = self.pathway.image
            else:
                imfilename = get_temp_imagefilename(self.pathway.image)
            im = Image.open(imfilename)
            cwidth, cheight = im.size
        else:
            # No image, so we set the canvas size to accommodate visible
            # elements
            cwidth, cheight = (self.pathway.bounds[1][0], self.pathway.bounds[1][1])
        # Instantiate canvas
        self.drawing = canvas.Canvas(
            filename,
            bottomup=0,
            pagesize=(
                cwidth * (1 + 2 * self.margins[0]),
                cheight * (1 + 2 * self.margins[1]),
            ),
        )
        self.drawing.setFont(self.fontname, self.fontsize)
        # Transform the canvas to add the margins
        self.drawing.translate(
            self.margins[0] * self.pathway.bounds[1][0],
            self.margins[1] * self.pathway.bounds[1][1],
        )
        # Add the map image, if required
        if self.import_imagemap:
            self.drawing.saveState()
            self.drawing.scale(1, -1)
            self.drawing.translate(0, -cheight)
            self.drawing.drawImage(imfilename, 0, 0)
            self.drawing.restoreState()
        # Add the reactions, compounds and maps
        # Maps go on first, to be overlaid by more information.
        # By default, they're slightly transparent.
        if self.show_maps:
            self.__add_maps()
        if self.show_reaction_entries:
            self.__add_reaction_entries()
        if self.show_orthologs:
            self.__add_orthologs()
        if self.show_compounds:
            self.__add_compounds()
        if self.show_genes:
            self.__add_genes()
        # TODO: complete draw_relations code
        # if self.draw_relations:
        #    self.__add_relations()
        # Write the pathway map to PDF
        self.drawing.save()

    def __add_maps(self):
        """Add maps to the drawing of the map (PRIVATE).

        We do this first, as they're regional labels to be overlaid by
        information.  Also, we want to set the color to something subtle.

        We're using Hex colors because that's what KGML uses, and
        Reportlab doesn't mind.
        """
        for m in self.pathway.maps:
            for g in m.graphics:
                self.drawing.setStrokeColor("#888888")
                self.drawing.setFillColor("#DDDDDD")
                self.__add_graphics(g)
                if self.label_maps:
                    self.drawing.setFillColor("#888888")
                    self.__add_labels(g)

    def __add_graphics(self, graphics):
        """Add the passed graphics object to the map (PRIVATE).

        Add text, add after the graphics object, for sane Z-ordering.
        """
        if graphics.type == "line":
            p = self.drawing.beginPath()
            x, y = graphics.coords[0]
            # There are optional settings for lines that aren't necessarily
            # part of the KGML DTD
            if graphics.width is not None:
                self.drawing.setLineWidth(graphics.width)
            else:
                self.drawing.setLineWidth(1)
            p.moveTo(x, y)
            for (x, y) in graphics.coords:
                p.lineTo(x, y)
            self.drawing.drawPath(p)
            self.drawing.setLineWidth(1)  # Return to default
        # KGML defines the (x, y) coordinates as the centre of the circle/
        # rectangle/roundrectangle, but Reportlab uses the coordinates of the
        # lower-left corner for rectangle/elif.
        if graphics.type == "circle":
            self.drawing.circle(
                graphics.x, graphics.y, graphics.width * 0.5, stroke=1, fill=1
            )
        elif graphics.type == "roundrectangle":
            self.drawing.roundRect(
                graphics.x - graphics.width * 0.5,
                graphics.y - graphics.height * 0.5,
                graphics.width,
                graphics.height,
                min(graphics.width, graphics.height) * 0.1,
                stroke=1,
                fill=1,
            )
        elif graphics.type == "rectangle":
            self.drawing.rect(
                graphics.x - graphics.width * 0.5,
                graphics.y - graphics.height * 0.5,
                graphics.width,
                graphics.height,
                stroke=1,
                fill=1,
            )

    def __add_labels(self, graphics):
        """Add labels for the passed graphics objects to the map (PRIVATE).

        We don't check that the labels fit inside objects such as circles/
        rectangles/roundrectangles.
        """
        if graphics.type == "line":
            # We use the midpoint of the line - sort of - we take the median
            # line segment (list-wise, not in terms of length), and use the
            # midpoint of that line.  We could have other options here,
            # maybe even parameterising it to a proportion of the total line
            # length.
            mid_idx = len(graphics.coords) * 0.5
            if not int(mid_idx) == mid_idx:
                idx1, idx2 = int(mid_idx - 0.5), int(mid_idx + 0.5)
            else:
                idx1, idx2 = int(mid_idx - 1), int(mid_idx)
            x1, y1 = graphics.coords[idx1]
            x2, y2 = graphics.coords[idx2]
            x, y = 0.5 * (x1 + x2), 0.5 * (y1 + y2)
        elif graphics.type == "circle":
            x, y = graphics.x, graphics.y
        elif graphics.type in ("rectangle", "roundrectangle"):
            x, y = graphics.x, graphics.y
        # How big so we want the text, and how many characters?
        if graphics._parent.type == "map":
            text = graphics.name
            self.drawing.setFont(self.fontname, self.fontsize + 2)
        elif len(graphics.name) < 15:
            text = graphics.name
        else:
            text = graphics.name[:12] + "..."
        self.drawing.drawCentredString(x, y, text)
        self.drawing.setFont(self.fontname, self.fontsize)

    def __add_orthologs(self):
        """Add 'ortholog' Entry elements to the drawing of the map (PRIVATE).

        In KGML, these are typically line objects, so we render them
        before the compound circles to cover the unsightly ends/junctions.
        """
        for ortholog in self.pathway.orthologs:
            for g in ortholog.graphics:
                self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor))
                self.drawing.setFillColor(color_to_reportlab(g.bgcolor))
                self.__add_graphics(g)
                if self.label_orthologs:
                    # We want the label color to be slightly darker
                    # (where possible), so it can be read
                    self.drawing.setFillColor(darken(g.fgcolor))
                    self.__add_labels(g)

    def __add_reaction_entries(self):
        """Add Entry elements for Reactions to the map drawing (PRIVATE).

        In KGML, these are typically line objects, so we render them
        before the compound circles to cover the unsightly ends/junctions
        """
        for reaction in self.pathway.reaction_entries:
            for g in reaction.graphics:
                self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor))
                self.drawing.setFillColor(color_to_reportlab(g.bgcolor))
                self.__add_graphics(g)
                if self.label_reaction_entries:
                    # We want the label color to be slightly darker
                    # (where possible), so it can be read
                    self.drawing.setFillColor(darken(g.fgcolor))
                    self.__add_labels(g)

    def __add_compounds(self):
        """Add compound elements to the drawing of the map (PRIVATE)."""
        for compound in self.pathway.compounds:
            for g in compound.graphics:
                # Modify transparency of compounds that don't participate
                # in reactions
                fillcolor = color_to_reportlab(g.bgcolor)
                if not compound.is_reactant:
                    fillcolor.alpha *= self.non_reactant_transparency
                self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor))
                self.drawing.setFillColor(fillcolor)
                self.__add_graphics(g)
                if self.label_compounds:
                    if not compound.is_reactant:
                        t = 0.3
                    else:
                        t = 1
                    self.drawing.setFillColor(colors.Color(0.2, 0.2, 0.2, t))
                    self.__add_labels(g)

    def __add_genes(self):
        """Add gene elements to the drawing of the map (PRIVATE)."""
        for gene in self.pathway.genes:
            for g in gene.graphics:
                self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor))
                self.drawing.setFillColor(color_to_reportlab(g.bgcolor))
                self.__add_graphics(g)
                if self.label_compounds:
                    self.drawing.setFillColor(darken(g.fgcolor))
                    self.__add_labels(g)

    def __add_relations(self):
        """Add relations to the map (PRIVATE).

        This is tricky. There is no defined graphic in KGML for a
        relation, and the corresponding entries are typically defined
        as objects 'to be connected somehow'.  KEGG uses KegSketch, which
        is not public, and most third-party software draws straight line
        arrows, with heads to indicate the appropriate direction
        (at both ends for reversible reactions), using solid lines for
        ECrel relation types, and dashed lines for maplink relation types.

        The relation has:
        - entry1: 'from' node
        - entry2: 'to' node
        - subtype: what the relation refers to

        Typically we have entry1 = map/ortholog; entry2 = map/ortholog,
        subtype = compound.
        """
        # Dashed lines for maplinks, solid for everything else
        for relation in list(self.pathway.relations):
            if relation.type == "maplink":
                self.drawing.setDash(6, 3)
            else:
                self.drawing.setDash()
            for s in relation.subtypes:
                subtype = self.pathway.entries[s[1]]
                # Our aim is to draw an arrow from the entry1 object to the
                # entry2 object, via the subtype object.
                # 1) Entry 1 to subtype
                self.__draw_arrow(relation.entry1, subtype)
                # 2) subtype to Entry 2
                self.__draw_arrow(subtype, relation.entry2)

    def __draw_arrow(self, g_from, g_to):
        """Draw an arrow between given Entry objects (PRIVATE).

        Draws an arrow from the g_from Entry object to the g_to
        Entry object; both must have Graphics objects.
        """
        # Centre and bound coordinates for the from and two objects
        bounds_from, bounds_to = g_from.bounds, g_to.bounds
        centre_from = (
            0.5 * (bounds_from[0][0] + bounds_from[1][0]),
            0.5 * (bounds_from[0][1] + bounds_from[1][1]),
        )
        centre_to = (
            0.5 * (bounds_to[0][0] + bounds_to[1][0]),
            0.5 * (bounds_to[0][1] + bounds_to[1][1]),
        )
        p = self.drawing.beginPath()
        # print(True, g_from.name, g_to.name, bounds_to, bounds_from)
        # If the 'from' and 'to' graphics are vertically-aligned, draw a line
        # from the 'from' to the 'to' entity
        if bounds_to[0][0] < centre_from[0] < bounds_to[1][0]:
            # print(True, g_from.name, g_to.name, bounds_to, bounds_from)
            if centre_to[1] > centre_from[1]:  # to above from
                p.moveTo(centre_from[0], bounds_from[1][1])
                p.lineTo(centre_from[0], bounds_to[0][1])
                # Draw arrow point - TODO
            else:  # to below from
                p.moveTo(centre_from[0], bounds_from[0][1])
                p.lineTo(centre_from[0], bounds_to[1][1])
                # Draw arrow point - TODO
        elif bounds_from[0][0] < centre_to[0] < bounds_from[1][0]:
            # print(True, g_from.name, g_to.name, bounds_to, bounds_from)
            if centre_to[1] > centre_from[1]:  # to above from
                p.moveTo(centre_to[0], bounds_from[1][1])
                p.lineTo(centre_to[0], bounds_to[0][1])
                # Draw arrow point - TODO
            else:  # to below from
                p.moveTo(centre_to[0], bounds_from[0][1])
                p.lineTo(centre_to[0], bounds_to[1][1])
                # Draw arrow point - TODO
        self.drawing.drawPath(p)  # Draw arrow shaft
        # print(g_from)
        # print(bounds_from)
        # print(g_to)
        # print(bounds_to)