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