1#
2#  Copyright (C) 2008 Greg Landrum
3#  Copyright (C) 2009 Uwe Hoffmann
4#
5#   @@ All Rights Reserved @@
6#  This file is part of the RDKit.
7#  The contents are covered by the terms of the BSD license
8#  which is included in the file license.txt, found at the root
9#  of the RDKit source tree.
10#
11import array
12import math
13import os
14import re
15
16from PIL import Image
17
18from rdkit.Chem.Draw.canvasbase import CanvasBase
19
20have_cairocffi = False
21# for Python3, import cairocffi preferably
22try:
23  import cairocffi as cairo
24except ImportError:
25  import cairo
26else:
27  have_cairocffi = True
28have_pango = False
29if 'RDK_NOPANGO' not in os.environ:
30  if have_cairocffi:
31    import cffi
32    import platform
33    ffi = cffi.FFI()
34    ffi.cdef('''
35        /* GLib */
36        typedef void* gpointer;
37        typedef void cairo_t;
38        typedef void PangoFontDescription;
39        void g_object_unref (gpointer object);
40
41        /* Pango and PangoCairo */
42        #define PANGO_SCALE 1024
43        typedef ... PangoLayout;
44        typedef enum {
45            PANGO_ALIGN_LEFT,
46            PANGO_ALIGN_CENTER,
47            PANGO_ALIGN_RIGHT
48        } PangoAlignment;
49        typedef struct PangoRectangle {
50          int x;
51          int y;
52          int width;
53          int height;
54        } PangoRectangle;
55        PangoLayout *pango_cairo_create_layout (cairo_t *cr);
56        void pango_cairo_update_layout (cairo_t *cr, PangoLayout *layout);
57        void pango_cairo_show_layout (cairo_t *cr, PangoLayout *layout);
58        void pango_layout_set_alignment (
59            PangoLayout *layout, PangoAlignment alignment);
60        void pango_layout_set_markup (
61            PangoLayout *layout, const char *text, int length);
62        void pango_layout_get_pixel_extents (PangoLayout *layout,
63            PangoRectangle *ink_rect, PangoRectangle *logical_rect);
64        PangoFontDescription *pango_font_description_new (void);
65        void pango_font_description_free (PangoFontDescription *desc);
66        void pango_font_description_set_family (PangoFontDescription *desc,
67            const char *family);
68        void pango_font_description_set_size (PangoFontDescription *desc,
69            int size);
70        void pango_layout_set_font_description (PangoLayout *layout,
71            const PangoFontDescription *desc);
72    ''')
73    if platform.system() == 'Windows':
74      defaultLibs = {
75        'pango_default_lib': 'libpango-1.0-0.dll',
76        'pangocairo_default_lib': 'libpangocairo-1.0-0.dll',
77        'gobject_default_lib': 'libgobject-2.0-0.dll'
78      }
79    else:
80      defaultLibs = {
81        'pango_default_lib': 'pango-1.0',
82        'pangocairo_default_lib': 'pangocairo-1.0',
83        'gobject_default_lib': 'gobject-2.0'
84      }
85    import ctypes.util
86    for libType in ['pango', 'pangocairo', 'gobject']:
87      envVar = 'RDK_' + libType.upper() + '_LIB'
88      envVarSet = False
89      if envVar in os.environ:
90        envVarSet = True
91        libName = os.environ[envVar]
92      else:
93        libName = defaultLibs[libType + '_default_lib']
94      libPath = ctypes.util.find_library(libName)
95      exec(libType + ' = None')
96      importError = False
97      if libPath:
98        try:
99          exec(libType + ' = ffi.dlopen("' + libPath.replace('\\', '\\\\') + '")')
100        except:
101          if envVarSet:
102            importError = True
103          else:
104            pass
105      else:
106        importError = True
107      if importError:
108        raise ImportError(envVar + ' set to ' + libName + ' but ' + libType.upper() +
109                          ' library cannot be loaded.')
110    have_pango = (pango and pangocairo and gobject)
111  else:
112    for libType in ['pango', 'pangocairo']:
113      try:
114        exec('import ' + libType)
115      except ImportError:
116        exec(libType + ' = None')
117    have_pango = (pango and pangocairo)
118
119if (not hasattr(cairo.ImageSurface, 'get_data')
120    and not hasattr(cairo.ImageSurface, 'get_data_as_rgba')):
121  raise ImportError('cairo version too old')
122
123scriptPattern = re.compile(r'\<.+?\>')
124
125
126class Canvas(CanvasBase):
127
128  def __init__(
129      self,
130      image=None,  # PIL image
131      size=None,
132      ctx=None,
133      imageType=None,  # determines file type
134      fileName=None,  # if set determines output file name
135  ):
136    """
137        Canvas can be used in four modes:
138        1) using the supplied PIL image
139        2) using the supplied cairo context ctx
140        3) writing to a file fileName with image type imageType
141        4) creating a cairo surface and context within the constructor
142        """
143    self.image = None
144    self.imageType = imageType
145    if image is not None:
146      try:
147        imgd = image.tobytes("raw", "BGRA")
148      except SystemError:
149        r, g, b, a = image.split()
150        mrg = Image.merge("RGBA", (b, g, r, a))
151        imgd = mrg.tobytes("raw", "RGBA")
152
153      a = array.array('B', imgd)
154      stride = image.size[0] * 4
155      surface = cairo.ImageSurface.create_for_data(a, cairo.FORMAT_ARGB32, image.size[0],
156                                                   image.size[1], stride)
157      ctx = cairo.Context(surface)
158      size = image.size[0], image.size[1]
159      self.image = image
160    elif ctx is None and size is not None:
161      if hasattr(cairo, "PDFSurface") and imageType == "pdf":
162        surface = cairo.PDFSurface(fileName, size[0], size[1])
163      elif hasattr(cairo, "SVGSurface") and imageType == "svg":
164        surface = cairo.SVGSurface(fileName, size[0], size[1])
165      elif hasattr(cairo, "PSSurface") and imageType == "ps":
166        surface = cairo.PSSurface(fileName, size[0], size[1])
167      elif imageType == "png":
168        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size[0], size[1])
169      else:
170        raise ValueError("Unrecognized file type. Valid choices are pdf, svg, ps, and png")
171      ctx = cairo.Context(surface)
172      ctx.set_source_rgb(1, 1, 1)
173      ctx.paint()
174    else:
175      surface = ctx.get_target()
176      if size is None:
177        try:
178          size = surface.get_width(), surface.get_height()
179        except AttributeError:
180          size = None
181    self.ctx = ctx
182    self.size = size
183    self.surface = surface
184    self.fileName = fileName
185
186  def flush(self):
187    """temporary interface, must be splitted to different methods,
188        """
189    if self.fileName and self.imageType == 'png':
190      self.surface.write_to_png(self.fileName)
191    elif self.image is not None:
192      # on linux at least it seems like the PIL images are BGRA, not RGBA:
193      if hasattr(self.surface, 'get_data'):
194        self.image.frombytes(bytes(self.surface.get_data()), "raw", "BGRA", 0, 1)
195      else:
196        self.image.frombytes(bytes(self.surface.get_data_as_rgba()), "raw", "RGBA", 0, 1)
197      self.surface.finish()
198    elif self.imageType == "png":
199      if hasattr(self.surface, 'get_data'):
200        buffer = self.surface.get_data()
201      else:
202        buffer = self.surface.get_data_as_rgba()
203      return buffer
204
205  def _doLine(self, p1, p2, **kwargs):
206    if kwargs.get('dash', (0, 0)) == (0, 0):
207      self.ctx.move_to(p1[0], p1[1])
208      self.ctx.line_to(p2[0], p2[1])
209    else:
210      dash = kwargs['dash']
211      pts = self._getLinePoints(p1, p2, dash)
212
213      currDash = 0
214      dashOn = True
215      while currDash < (len(pts) - 1):
216        if dashOn:
217          p1 = pts[currDash]
218          p2 = pts[currDash + 1]
219          self.ctx.move_to(p1[0], p1[1])
220          self.ctx.line_to(p2[0], p2[1])
221        currDash += 1
222        dashOn = not dashOn
223
224  def addCanvasLine(self, p1, p2, color=(0, 0, 0), color2=None, **kwargs):
225    self.ctx.set_line_width(kwargs.get('linewidth', 1))
226    if color2 and color2 != color:
227      mp = (p1[0] + p2[0]) / 2., (p1[1] + p2[1]) / 2.
228      self.ctx.set_source_rgb(*color)
229      self._doLine(p1, mp, **kwargs)
230      self.ctx.stroke()
231      self.ctx.set_source_rgb(*color2)
232      self._doLine(mp, p2, **kwargs)
233      self.ctx.stroke()
234    else:
235      self.ctx.set_source_rgb(*color)
236      self._doLine(p1, p2, **kwargs)
237      self.ctx.stroke()
238
239  def _addCanvasText1(self, text, pos, font, color=(0, 0, 0), **kwargs):
240    if font.weight == 'bold':
241      weight = cairo.FONT_WEIGHT_BOLD
242    else:
243      weight = cairo.FONT_WEIGHT_NORMAL
244    self.ctx.select_font_face(font.face, cairo.FONT_SLANT_NORMAL, weight)
245    text = scriptPattern.sub('', text)
246    self.ctx.set_font_size(font.size)
247    w, h = self.ctx.text_extents(text)[2:4]
248    bw, bh = w + h * 0.4, h * 1.4
249    offset = w * pos[2]
250    dPos = pos[0] - w / 2. + offset, pos[1] + h / 2.
251    self.ctx.set_source_rgb(*color)
252    self.ctx.move_to(*dPos)
253    self.ctx.show_text(text)
254
255    if 0:
256      self.ctx.move_to(dPos[0], dPos[1])
257      self.ctx.line_to(dPos[0] + bw, dPos[1])
258      self.ctx.line_to(dPos[0] + bw, dPos[1] - bh)
259      self.ctx.line_to(dPos[0], dPos[1] - bh)
260      self.ctx.line_to(dPos[0], dPos[1])
261      self.ctx.close_path()
262      self.ctx.stroke()
263
264    return (bw, bh, offset)
265
266  def _addCanvasText2(self, text, pos, font, color=(0, 0, 0), **kwargs):
267    if font.weight == 'bold':
268      weight = cairo.FONT_WEIGHT_BOLD
269    else:
270      weight = cairo.FONT_WEIGHT_NORMAL
271    self.ctx.select_font_face(font.face, cairo.FONT_SLANT_NORMAL, weight)
272    orientation = kwargs.get('orientation', 'E')
273
274    plainText = scriptPattern.sub('', text)
275
276    # for whatever reason, the font size using pango is larger
277    # than that w/ default cairo (at least for me)
278    pangoCoeff = 0.8
279
280    if have_cairocffi:
281      measureLout = pangocairo.pango_cairo_create_layout(self.ctx._pointer)
282      pango.pango_layout_set_alignment(measureLout, pango.PANGO_ALIGN_LEFT)
283      pango.pango_layout_set_markup(measureLout, plainText.encode('latin1'), -1)
284      lout = pangocairo.pango_cairo_create_layout(self.ctx._pointer)
285      pango.pango_layout_set_alignment(lout, pango.PANGO_ALIGN_LEFT)
286      pango.pango_layout_set_markup(lout, text.encode('latin1'), -1)
287      fnt = pango.pango_font_description_new()
288      pango.pango_font_description_set_family(fnt, font.face.encode('latin1'))
289      pango.pango_font_description_set_size(fnt,
290                                            int(round(font.size * pango.PANGO_SCALE * pangoCoeff)))
291      pango.pango_layout_set_font_description(lout, fnt)
292      pango.pango_layout_set_font_description(measureLout, fnt)
293      pango.pango_font_description_free(fnt)
294    else:
295      cctx = pangocairo.CairoContext(self.ctx)
296      measureLout = cctx.create_layout()
297      measureLout.set_alignment(pango.ALIGN_LEFT)
298      measureLout.set_markup(plainText)
299      lout = cctx.create_layout()
300      lout.set_alignment(pango.ALIGN_LEFT)
301      lout.set_markup(text)
302      fnt = pango.FontDescription('%s %d' % (font.face, font.size * pangoCoeff))
303      lout.set_font_description(fnt)
304      measureLout.set_font_description(fnt)
305
306    # this is a bit kludgy, but empirically we end up with too much
307    # vertical padding if we use the text box with super and subscripts
308    # for the measurement.
309    if have_cairocffi:
310      iext = ffi.new('PangoRectangle *')
311      lext = ffi.new('PangoRectangle *')
312      iext2 = ffi.new('PangoRectangle *')
313      lext2 = ffi.new('PangoRectangle *')
314      pango.pango_layout_get_pixel_extents(measureLout, iext, lext)
315      pango.pango_layout_get_pixel_extents(lout, iext2, lext2)
316      w = lext2.width - lext2.x
317      h = lext.height - lext.y
318    else:
319      iext, lext = measureLout.get_pixel_extents()
320      iext2, lext2 = lout.get_pixel_extents()
321      w = lext2[2] - lext2[0]
322      h = lext[3] - lext[1]
323    pad = [h * .2, h * .3]
324    # another empirical correction: labels draw at the bottom
325    # of bonds have too much vertical padding
326    if orientation == 'S':
327      pad[1] *= 0.5
328    bw, bh = w + pad[0], h + pad[1]
329    offset = w * pos[2]
330    if 0:
331      if orientation == 'W':
332        dPos = pos[0] - w + offset, pos[1] - h / 2.
333      elif orientation == 'E':
334        dPos = pos[0] - w / 2 + offset, pos[1] - h / 2.
335      else:
336        dPos = pos[0] - w / 2 + offset, pos[1] - h / 2.
337      self.ctx.move_to(dPos[0], dPos[1])
338    else:
339      dPos = pos[0] - w / 2. + offset, pos[1] - h / 2.
340      self.ctx.move_to(dPos[0], dPos[1])
341
342    self.ctx.set_source_rgb(*color)
343    if have_cairocffi:
344      pangocairo.pango_cairo_update_layout(self.ctx._pointer, lout)
345      pangocairo.pango_cairo_show_layout(self.ctx._pointer, lout)
346      gobject.g_object_unref(lout)
347      gobject.g_object_unref(measureLout)
348    else:
349      cctx.update_layout(lout)
350      cctx.show_layout(lout)
351
352    if 0:
353      self.ctx.move_to(dPos[0], dPos[1])
354      self.ctx.line_to(dPos[0] + bw, dPos[1])
355      self.ctx.line_to(dPos[0] + bw, dPos[1] + bh)
356      self.ctx.line_to(dPos[0], dPos[1] + bh)
357      self.ctx.line_to(dPos[0], dPos[1])
358      self.ctx.close_path()
359      self.ctx.stroke()
360
361    return (bw, bh, offset)
362
363  def addCanvasText(self, text, pos, font, color=(0, 0, 0), **kwargs):
364    if have_pango:
365      textSize = self._addCanvasText2(text, pos, font, color, **kwargs)
366    else:
367      textSize = self._addCanvasText1(text, pos, font, color, **kwargs)
368    return textSize
369
370  def addCanvasPolygon(self, ps, color=(0, 0, 0), fill=True, stroke=False, **kwargs):
371    if not fill and not stroke:
372      return
373    self.ctx.set_source_rgb(*color)
374    self.ctx.move_to(ps[0][0], ps[0][1])
375    for p in ps[1:]:
376      self.ctx.line_to(p[0], p[1])
377    self.ctx.close_path()
378    if stroke:
379      if fill:
380        self.ctx.stroke_preserve()
381      else:
382        self.ctx.stroke()
383    if fill:
384      self.ctx.fill()
385
386  def addCanvasDashedWedge(self, p1, p2, p3, dash=(2, 2), color=(0, 0, 0), color2=None, **kwargs):
387    self.ctx.set_line_width(kwargs.get('linewidth', 1))
388    self.ctx.set_source_rgb(*color)
389    dash = (3, 3)
390    pts1 = self._getLinePoints(p1, p2, dash)
391    pts2 = self._getLinePoints(p1, p3, dash)
392
393    if len(pts2) < len(pts1):
394      pts2, pts1 = pts1, pts2
395
396    for i in range(len(pts1)):
397      self.ctx.move_to(pts1[i][0], pts1[i][1])
398      self.ctx.line_to(pts2[i][0], pts2[i][1])
399    self.ctx.stroke()
400
401  def addCircle(self, center, radius, color=(0, 0, 0), fill=True, stroke=False, alpha=1.0,
402                **kwargs):
403    if not fill and not stroke:
404      return
405    self.ctx.set_source_rgba(color[0], color[1], color[2], alpha)
406    self.ctx.arc(center[0], center[1], radius, 0, 2. * math.pi)
407    self.ctx.close_path()
408    if stroke:
409      if fill:
410        self.ctx.stroke_preserve()
411      else:
412        self.ctx.stroke()
413    if fill:
414      self.ctx.fill()
415