Gnumed.timelinelib.canvas.svg

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