Package Gnumed :: Package timelinelib :: Package canvas :: Module svg |
[frames] | no frames]

# Source Code for Module Gnumed.timelinelib.canvas.svg

      1  # Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Rickard Lindberg, Roger Lindberg
      2  #
      3  # This file is part of Timeline.
      4  #
      5  # Timeline is free software: you can redistribute it and/or modify
      6  # it under the terms of the GNU General Public License as published by
      7  # the Free Software Foundation, either version 3 of the License, or
      8  # (at your option) any later version.
      9  #
     10  # Timeline is distributed in the hope that it will be useful,
     11  # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13  # GNU General Public License for more details.
     14  #
     15  # You should have received a copy of the GNU General Public License
     16  # along with Timeline.  If not, see <http://www.gnu.org/licenses/>.
     17
     18
     19  from types import UnicodeType
     20  from xml.sax.saxutils import escape as xmlescape
     21
     22  from pysvg.filter import feGaussianBlur
     23  from pysvg.filter import feOffset
     24  from pysvg.filter import feMerge
     25  from pysvg.filter import feMergeNode
     26  from pysvg.filter import filter
     27  from pysvg.builders import StyleBuilder
     28  from pysvg.builders import ShapeBuilder
     29  from pysvg.structure import g
     30  from pysvg.structure import svg
     31  from pysvg.structure import defs
     32  from pysvg.shape import path
     33  from pysvg.structure import clipPath
     34  from pysvg.text import text
     35
     36  from timelinelib.canvas.drawing.utils import darken_color
     37  from timelinelib.canvas.data import sort_categories
     38  from timelinelib.features.experimental.experimentalfeatures import EXTENDED_CONTAINER_HEIGHT
     39
     40
     41  OUTER_PADDING = 5  # Space between event boxes (pixels)
     42  INNER_PADDING = 3  # Space inside event box to text (pixels)
     43  DATA_INDICATOR_SIZE = 10
     44  SMALL_FONT_SIZE_PX = 11
     45  LARGER_FONT_SIZE_PX = 14
     46  Y_RECT_OFFSET = 12
     47  Y_TEXT_OFFSET = 18
     48  ENCODING = "utf-8"
     49
     50
     51 -def export(path, timeline, scene, view_properties, appearence):
     52      svgDrawer = SVGDrawingAlgorithm(timeline, scene, view_properties, appearence, shadow=True)
     53      svgDrawer.draw()
     54      svgDrawer.write(path)
     55
     56
     57 -class SVGDrawingAlgorithm(object):
     58
     59      # options:  shadow=True|False
     60
     61 -    def __init__(self, timeline, scene, view_properties, appearence, **kwargs):
     62          self._timeline = timeline
     63          self._scene = scene
     64          self._appearence = appearence
     65          self._view_properties = view_properties
     66          self._svg = svg(width=scene.width, height=scene.height)
     67          self._small_font_style = self._get_small_font_style()
     68          self._small_centered_font_style = self._get_small_centered_font_style()
     69          self._larger_font_style = self._get_larger_font_style()
     70          try:
     71              self._shadow_flag = kwargs["shadow"]
     72          except KeyError:
     73              self._shadow_flag = False
     74
     75 -    def write(self, path):
     76          """
     77          write the SVG code into the file with filename path. No
     78          checking is done if file/path exists
     79          """
     80          self._svg.save(path, encoding=ENCODING)
     81
     82 -    def draw(self):
     83          for element in self._get_elements():
     84              self._svg.addElement(element)
     85
     86 -    def _get_elements(self):
     87          elements = [self._define_shadow_filter(), self._get_bg()]
     88          elements.extend(self._get_events())
     89          elements.extend(self._get_legend())
     90          return elements
     91
     92 -    def _get_events(self):
     93          return [self._draw_event(event, rect) for (event, rect) in self._scene.event_data]
     94
     95 -    def _get_legend(self):
     96          categories = self._extract_categories()
     97          return [item for item in [self._draw_legend(categories)]
     98                  if self._legend_should_be_drawn(categories)]
     99
    100 -    def _get_bg(self):
    101          """
    102          Draw background color
    103          Draw background Era strips and labels
    104          Draw major and minor strips, lines to all event boxes and baseline.
    105          Both major and minor strips have divider lines and labels.
    106          Draw now line if it is visible
    107          """
    108          group = g()
    109          group.addElement(self._draw_background())
    110          for era in self._timeline.get_all_periods():
    111              group.addElement(self._draw_era_strip(era))
    112              group.addElement(self._draw_era_text(era))
    113          for strip in self._scene.minor_strip_data:
    114              group.addElement(self._draw_minor_strip_divider_line(strip.end_time))
    115              group.addElement(self._draw_minor_strip_label(strip))
    116          for strip in self._scene.major_strip_data:
    117              group.addElement(self._draw_major_strip_divider_line(strip.end_time))
    118              group.addElement(self._draw_major_strip_label(strip))
    119          group.addElement(self._draw_divider_line())
    120          self._draw_lines_to_non_period_events(group, self._view_properties)
    121          if self._now_line_is_visible():
    122              group.addElement(self._draw_now_line())
    123          return group
    124
    125 -    def _draw_background(self):
    126          svg_color = self._map_svg_color(self._appearence.get_bg_colour()[:3])
    127          return ShapeBuilder().createRect(0, 0, self._scene.width, self._scene.height, fill=svg_color)
    128
    129 -    def _draw_era_strip(self, era):
    130          svg_color = self._map_svg_color(era.get_color()[:3])
    131          x, width = self._calc_era_strip_metrics(era)
    132          return ShapeBuilder().createRect(x, INNER_PADDING, width,
    133                                           self._scene.height - 2 * INNER_PADDING,
    134                                           fill=svg_color, strokewidth=0)
    135
    136 -    def _draw_era_text(self, era):
    137          x, y = self._calc_era_text_metrics(era)
    138          return self._draw_label(era.get_name(), x, y, self._small_centered_font_style)
    139
    140 -    def _calc_era_strip_metrics(self, era):
    141          period = era.get_time_period()
    142          x = self._scene.x_pos_for_time(period.start_time)
    143          width = min(self._scene.x_pos_for_time(period.end_time), self._scene.width) - x
    144          return x, width
    145
    146 -    def _calc_era_text_metrics(self, era):
    147          period = era.get_time_period()
    148          _, width = self._calc_era_strip_metrics(era)
    149          x = self._scene.x_pos_for_time(period.start_time) + width / 2
    150          y = self._scene.height - OUTER_PADDING
    151          return x, y
    152
    153 -    def _draw_minor_strip_divider_line(self, time):
    154          return self._draw_vertical_line(self._scene.x_pos_for_time(time), "lightgrey")
    155
    156 -    def _draw_minor_strip_label(self, strip_period):
    157          label = self._scene.minor_strip.label(strip_period.start_time)
    158          x = self._calc_x_for_minor_strip_label(strip_period)
    159          y = self._calc_y_for_minor_strip_label()
    160          return self._draw_label(label, x, y, self._small_font_style)
    161
    162 -    def _calc_x_for_minor_strip_label(self, strip_period):
    163          return (self._scene.x_pos_for_time(strip_period.start_time) +
    164                  self._scene.x_pos_for_time(strip_period.end_time)) / 2 - SMALL_FONT_SIZE_PX
    165
    166 -    def _calc_y_for_minor_strip_label(self):
    167          return self._scene.divider_y - OUTER_PADDING
    168
    169 -    def _draw_label(self, label, x, y, style):
    170          text = self._text(label, x, y)
    171          text.set_style(style.getStyle())
    172          return text
    173
    174 -    def _draw_major_strip_divider_line(self, time):
    175          return self._draw_vertical_line(self._scene.x_pos_for_time(time), "black")
    176
    177 -    def _draw_vertical_line(self, x, colour):
    178          return ShapeBuilder().createLine(x, 0, x, self._scene.height, strokewidth=0.5, stroke=colour)
    179
    180 -    def _draw_major_strip_label(self, tp):
    181          label = self._scene.major_strip.label(tp.start_time, True)
    182          # If the label is not visible when it is positioned in the middle
    183          # of the period, we move it so that as much of it as possible is
    184          # visible without crossing strip borders.
    185          # since there is no function like textwidth() for SVG, just take into account that text can be overwritten
    186          # do not perform a special handling for right border, SVG is unlimited
    187          x = (max(0, self._scene.x_pos_for_time(tp.start_time)) +
    188               min(self._scene.width, self._scene.x_pos_for_time(tp.end_time))) / 2
    189          y = LARGER_FONT_SIZE_PX + OUTER_PADDING
    190          return self._draw_label(label, x, y, self._larger_font_style)
    191
    192 -    def _draw_divider_line(self):
    193          return ShapeBuilder().createLine(0, self._scene.divider_y, self._scene.width,
    194                                           self._scene.divider_y, strokewidth=0.5, stroke="grey")
    195
    196 -    def _draw_lines_to_non_period_events(self, group, view_properties):
    197          for (event, rect) in self._scene.event_data:
    198              if rect.Y < self._scene.divider_y:
    199                  line, circle = self._draw_line_to_non_period_event(view_properties, event, rect)
    200                  group.addElement(line)
    201                  group.addElement(circle)
    202
    203 -    def _draw_line_to_non_period_event(self, view_properties, event, rect):
    204          x = self._scene.x_pos_for_time(event.mean_time())
    205          y = rect.Y + rect.Height / 2
    206          stroke = {True: "red", False: "black"}[view_properties.is_selected(event)]
    207          line = ShapeBuilder().createLine(x, y, x, self._scene.divider_y, stroke=stroke)
    208          circle = ShapeBuilder().createCircle(x, self._scene.divider_y, 2)
    209          return line, circle
    210
    211 -    def _draw_now_line(self):
    212          return self._draw_vertical_line(self._scene.x_pos_for_now(), "darkred")
    213
    214 -    def _now_line_is_visible(self):
    215          x = self._scene.x_pos_for_now()
    216          return x > 0 and x < self._scene.width
    217
    218 -    def _get_event_border_color(self, event):
    219          return self._map_svg_color(darken_color(self._get_event_color(event)))
    220
    221 -    def _get_event_box_color(self, event):
    222          return self._map_svg_color(self._get_event_color(event))
    223
    224 -    def _get_box_indicator_color(self, event):
    225          return self._map_svg_color(darken_color(self._get_event_color(event), 0.6))
    226
    227 -    def _get_event_color(self, event):
    228          if event.category:
    229              return event.category.color
    230          else:
    231              return event.get_default_color()
    232
    233 -    def _map_svg_color(self, color):
    234          """
    235          map (r,g,b) color to svg string
    236          """
    237          return "#%02X%02X%02X" % color
    238
    239 -    def _legend_should_be_drawn(self, categories):
    240          return self._appearence.get_legend_visible() and len(categories) > 0
    241
    242 -    def _extract_categories(self):
    243          categories = set([event.category for (event, _) in self._scene.event_data
    244                            if event.category])
    245          return sort_categories(list(categories))
    246
    247 -    def _draw_legend(self, categories):
    248          """
    249          Draw legend for the given categories.
    250
    251          Box in lower right corner
    252              Motivation for positioning in right corner:
    253              SVG text cannot be centered since the text width cannot be calculated
    254              and the first part of each event text is important.
    255              ergo: text needs to be left aligned.
    256                    But then the probability is high that a lot of text is at the left
    257                    bottom
    258                    ergo: put the legend to the right.
    259
    260            +----------+
    261            | Name   O |
    262            | Name   O |
    263            +----------+
    264          """
    265          group = g()
    266          group.addElement(self._draw_categories_box(len(categories)))
    267          cur_y = self._get_categories_box_y(len(categories)) + OUTER_PADDING
    268          for cat in categories:
    269              color_box, label = self._draw_category(self._get_categories_box_width(),
    270                                                     self._get_categories_item_height(),
    271                                                     self._get_categories_box_x(), cur_y, cat)
    272              group.addElement(color_box)
    273              group.addElement(label)
    274              cur_y = cur_y + self._get_categories_item_height() + INNER_PADDING
    275          return group
    276
    277 -    def _draw_categories_box(self, nbr_of_categories):
    278          return ShapeBuilder().createRect(self._get_categories_box_x(),
    279                                           self._get_categories_box_y(nbr_of_categories),
    280                                           self._get_categories_box_width(),
    281                                           self._get_categories_box_height(nbr_of_categories),
    282                                           fill='white')
    283
    284 -    def _get_categories_box_width(self):
    285          # reserve 15% for the legend
    286          return int(self._scene.width * 0.15)
    287
    288 -    def _get_categories_item_height(self):
    289          return SMALL_FONT_SIZE_PX + OUTER_PADDING
    290
    291 -    def _get_categories_box_height(self, nbr_of_categories):
    292          return nbr_of_categories * (self._get_categories_item_height() + INNER_PADDING) + 2 * OUTER_PADDING - INNER_PADDING
    293
    294 -    def _get_categories_box_x(self):
    295          return self._scene.width - self._get_categories_box_width() - OUTER_PADDING
    296
    297 -    def _get_categories_box_y(self, nbr_of_categories):
    298          return self._scene.height - self._get_categories_box_height(nbr_of_categories) - OUTER_PADDING
    299
    300 -    def _draw_category(self, width, item_height, x, y, cat):
    301          return (self._draw_category_color_box(item_height, x, y, cat),
    302                  self._draw_category_label(width, item_height, x, y, cat))
    303
    304 -    def _draw_category_color_box(self, item_height, x, y, cat):
    305          base_color = self._map_svg_color(cat.color)
    306          border_color = self._map_svg_color(darken_color(cat.color))
    307          return ShapeBuilder().createRect(x + OUTER_PADDING,
    308                                           y, item_height, item_height, fill=base_color,
    309                                           stroke=border_color)
    310
    311 -    def _draw_category_label(self, width, item_height, x, y, cat):
    312          return self._svg_clipped_text(cat.name,
    313                                        (x + OUTER_PADDING + INNER_PADDING + item_height,
    314                                         y, width - OUTER_PADDING - INNER_PADDING - item_height,
    315                                         item_height),
    316                                        self._get_small_font_style())
    317
    318 -    def _draw_event(self, event, rect):
    319          if self._scene.center_text():
    320              style = self._small_centered_font_style
    321          else:
    322              style = self._small_font_style
    323          group = g()
    324          group.addElement(self._draw_event_rect(event, rect))
    325          text_rect = rect.Get()
    326          if event.is_container() and EXTENDED_CONTAINER_HEIGHT.enabled():
    327              text_rect = rect.Get()
    328              text_rect = (text_rect[0], text_rect[1] - Y_TEXT_OFFSET, text_rect[2], text_rect[3])
    329          group.addElement(self._svg_clipped_text(event.text, text_rect, style,
    330                                                  self._scene.center_text()))
    331          if event.has_data():
    332              group.addElement(self._draw_contents_indicator(event, rect))
    333          return group
    334
    335 -    def _draw_event_rect(self, event, rect):
    336          boxBorderColor = self._get_event_border_color(event)
    337          if event.is_container() and EXTENDED_CONTAINER_HEIGHT.enabled():
    338              svg_rect = ShapeBuilder().createRect(rect.X, rect.Y - Y_RECT_OFFSET, rect.GetWidth(),
    339                                                   rect.GetHeight() + Y_RECT_OFFSET,
    340                                                   stroke=boxBorderColor,
    341                                                   fill=self._get_event_box_color(event))
    342          else:
    343              svg_rect = ShapeBuilder().createRect(rect.X, rect.Y, rect.GetWidth(), rect.GetHeight(),
    344                                                   stroke=boxBorderColor, fill=self._get_event_box_color(event))
    345          if self._shadow_flag:
    346              svg_rect.set_filter("url(#filterShadow)")
    347          return svg_rect
    348
    349 -    def _draw_contents_indicator(self, event, rect):
    350          """
    351          The data contents indicator is a small triangle drawn in the upper
    352          right corner of the event rectangle.
    353          """
    354          corner_x = rect.X + rect.Width
    355          points = "%d,%d %d,%d %d,%d" % \
    356              (corner_x - DATA_INDICATOR_SIZE, rect.Y,
    357               corner_x, rect.Y,
    358               corner_x, rect.Y + DATA_INDICATOR_SIZE)
    359          color = self._get_box_indicator_color(event)
    360          indicator = ShapeBuilder().createPolygon(points, fill=color, stroke=color)
    361          # TODO (low): Transparency ?
    362          return indicator
    363
    364 -    def _svg_clipped_text(self, text, rect, style, center_text=False):
    365          group = g()
    366          group.set_clip_path("url(#%s)" % self._create_clip_path(rect))
    367          group.addElement(self._draw_text(text, rect, style, center_text))
    368          return group
    369
    370 -    def _create_clip_path(self, rect):
    371          path_id, path = self._calc_clip_path(rect)
    372          clip = clipPath()
    373          clip.addElement(path)
    374          clip.set_id(path_id)
    375          self._svg.addElement(self._create_defs(clip))
    376          return path_id
    377
    378 -    def _calc_clip_path(self, rect):
    379          rx, ry, width, height = rect
    380          if rx < 0:
    381              width += rx
    382              rx = 0
    383          pathId = "path%d_%d_%d" % (rx, ry, width)
    384          p = path(pathData="M %d %d H %d V %d H %d" %
    385                   (rx, ry + height, rx + width, ry, rx))
    386          return pathId, p
    387
    388 -    def _draw_text(self, my_text, rect, style, center_text=False):
    389          my_text = self._encode_text(my_text)
    390          x, y = self._calc_text_pos(rect, center_text)
    391          label = text(my_text, x, y)
    392          label.set_style(style.getStyle())
    393          label.set_lengthAdjust("spacingAndGlyphs")
    394          return label
    395
    396 -    def _calc_text_pos(self, rect, center_text=False):
    397          rx, ry, width, height = rect
    398          # In SVG, negative value should be OK, but they
    399          # are not drawn in Firefox. So add a special handling here.
    400          if rx < 0:
    401              width += rx
    402              x = 0
    403          else:
    404              x = rx + INNER_PADDING
    405          if center_text:
    406              x += (width - 2 * INNER_PADDING) / 2
    407          y = ry + height - INNER_PADDING
    408          return x, y
    409
    410 -    def _text(self, the_text, x, y):
    411          encoded_text = self._encode_text(the_text)
    412          return text(encoded_text, x, y)
    413
    414 -    def _encode_text(self, text):
    415          return self._encode_unicode_text(xmlescape(text))
    416
    417 -    def _encode_unicode_text(self, text):
    418          if type(text) is UnicodeType:
    419              return text.encode(ENCODING)
    420          else:
    421              return text
    422
    423 -    def _define_shadow_filter(self):
    424          return self._create_defs(self._get_shadow_filter())
    425
    426 -    def _create_defs(self, definition):
    427          d = defs()
    428          d.addElement(definition)
    429          return d
    430
    431 -    def _get_small_font_style(self):
    432          return self._get_font_style(SMALL_FONT_SIZE_PX, 'left', (2, 2))
    433
    434 -    def _get_small_centered_font_style(self):
    435          return self._get_font_style(SMALL_FONT_SIZE_PX, 'middle', (2, 2))
    436
    437 -    def _get_larger_font_style(self):
    438          return self._get_font_style(LARGER_FONT_SIZE_PX, 'left', "")
    439
    440 -    def _get_font_style(self, size, anchor, dash_array):
    441          style = StyleBuilder()
    442          style.setStrokeDashArray(dash_array)
    443          style.setFontFamily(fontfamily="Verdana")
    444          style.setFontSize("%dpx" % size)
    445          style.setTextAnchor(anchor)
    446          return style
    447
    448 -    def _get_shadow_filter(self):
    449          filterShadow = filter(x="-.3", y="-.5", width=1.9, height=1.9)
    450          filtBlur = feGaussianBlur(stdDeviation="4")
    451          filtBlur.set_in("SourceAlpha")
    452          filtBlur.set_result("out1")
    453          filtOffset = feOffset()
    454          filtOffset.set_in("out1")
    455          filtOffset.set_dx(4)
    456          filtOffset.set_dy(-4)
    457          filtOffset.set_result("out2")
    458          filtMergeNode1 = feMergeNode()
    459          filtMergeNode1.set_in("out2")
    460          filtMergeNode2 = feMergeNode()
    461          filtMergeNode2.set_in("SourceGraphic")
    462          filtMerge = feMerge()
    463          filtMerge.addElement(filtMergeNode1)
    464          filtMerge.addElement(filtMergeNode2)
    465          filterShadow.addElement(filtBlur)  # here i get an error from python. It is not allowed to add a primitive filter
    466          filterShadow.addElement(filtOffset)
    467          filterShadow.addElement(filtMerge)
    468          filterShadow.set_id("filterShadow")
    469          return filterShadow
    470