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