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
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
285 # reserve 15% for the legend 286 return int(self._scene.width * 0.15)
287 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
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