1#Copyright ReportLab Europe Ltd. 2000-2017 2#see license.txt for license details 3#history https://hg.reportlab.com/hg-public/reportlab/log/tip/src/reportlab/graphics/charts/textlabels.py 4__version__='3.3.0' 5import string 6 7from reportlab.lib import colors 8from reportlab.lib.utils import simpleSplit, _simpleSplit 9from reportlab.lib.validators import isNumber, isNumberOrNone, OneOf, isColorOrNone, isString, \ 10 isTextAnchor, isBoxAnchor, isBoolean, NoneOr, isInstanceOf, isNoneOrString, isNoneOrCallable, \ 11 isSubclassOf 12from reportlab.lib.attrmap import * 13from reportlab.pdfbase.pdfmetrics import stringWidth, getAscentDescent, getFont 14from reportlab.graphics.shapes import Drawing, Group, Circle, Rect, String, STATE_DEFAULTS 15from reportlab.graphics.widgetbase import Widget, PropHolder 16from reportlab.graphics.shapes import _baseGFontName, DirectDraw 17from reportlab.platypus import XPreformatted, Paragraph, Flowable 18from reportlab.lib.styles import ParagraphStyle, PropertySet 19from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER 20_ta2al = dict(start=TA_LEFT,end=TA_RIGHT,middle=TA_CENTER) 21from ..utils import (text2Path as _text2Path, #here for continuity 22 pathNumTrunc as _pathNumTrunc, 23 processGlyph as _processGlyph, 24 text2PathDescription as _text2PathDescription) 25 26_A2BA= { 27 'x': {0:'n', 45:'ne', 90:'e', 135:'se', 180:'s', 225:'sw', 270:'w', 315: 'nw', -45: 'nw'}, 28 'y': {0:'e', 45:'se', 90:'s', 135:'sw', 180:'w', 225:'nw', 270:'n', 315: 'ne', -45: 'ne'}, 29 } 30 31try: 32 from rlextra.graphics.canvasadapter import DirectDrawFlowable 33except ImportError: 34 DirectDrawFlowable = None 35 36_BA2TA={'w':'start','nw':'start','sw':'start','e':'end', 'ne': 'end', 'se':'end', 'n':'middle','s':'middle','c':'middle'} 37class Label(Widget): 38 """A text label to attach to something else, such as a chart axis. 39 40 This allows you to specify an offset, angle and many anchor 41 properties relative to the label's origin. It allows, for example, 42 angled multiline axis labels. 43 """ 44 # fairly straight port of Robin Becker's textbox.py to new widgets 45 # framework. 46 47 _attrMap = AttrMap( 48 x = AttrMapValue(isNumber,desc=''), 49 y = AttrMapValue(isNumber,desc=''), 50 dx = AttrMapValue(isNumber,desc='delta x - offset'), 51 dy = AttrMapValue(isNumber,desc='delta y - offset'), 52 angle = AttrMapValue(isNumber,desc='angle of label: default (0), 90 is vertical, 180 is upside down, etc'), 53 boxAnchor = AttrMapValue(isBoxAnchor,desc='anchoring point of the label'), 54 boxStrokeColor = AttrMapValue(isColorOrNone,desc='border color of the box'), 55 boxStrokeWidth = AttrMapValue(isNumber,desc='border width'), 56 boxFillColor = AttrMapValue(isColorOrNone,desc='the filling color of the box'), 57 boxTarget = AttrMapValue(OneOf('normal','anti','lo','hi'),desc="one of ('normal','anti','lo','hi')"), 58 fillColor = AttrMapValue(isColorOrNone,desc='label text color'), 59 strokeColor = AttrMapValue(isColorOrNone,desc='label text border color'), 60 strokeWidth = AttrMapValue(isNumber,desc='label text border width'), 61 text = AttrMapValue(isString,desc='the actual text to display'), 62 fontName = AttrMapValue(isString,desc='the name of the font used'), 63 fontSize = AttrMapValue(isNumber,desc='the size of the font'), 64 leading = AttrMapValue(isNumberOrNone,desc=''), 65 width = AttrMapValue(isNumberOrNone,desc='the width of the label'), 66 maxWidth = AttrMapValue(isNumberOrNone,desc='maximum width the label can grow to'), 67 height = AttrMapValue(isNumberOrNone,desc='the height of the text'), 68 textAnchor = AttrMapValue(isTextAnchor,desc='the anchoring point of the text inside the label'), 69 visible = AttrMapValue(isBoolean,desc="True if the label is to be drawn"), 70 topPadding = AttrMapValue(isNumber,desc='padding at top of box'), 71 leftPadding = AttrMapValue(isNumber,desc='padding at left of box'), 72 rightPadding = AttrMapValue(isNumber,desc='padding at right of box'), 73 bottomPadding = AttrMapValue(isNumber,desc='padding at bottom of box'), 74 useAscentDescent = AttrMapValue(isBoolean,desc="If True then the font's Ascent & Descent will be used to compute default heights and baseline."), 75 customDrawChanger = AttrMapValue(isNoneOrCallable,desc="An instance of CustomDrawChanger to modify the behavior at draw time", _advancedUsage=1), 76 ddf = AttrMapValue(NoneOr(isSubclassOf(DirectDraw),'NoneOrDirectDraw'),desc="A DirectDrawFlowable instance", _advancedUsage=1), 77 ddfKlass = AttrMapValue(NoneOr(isSubclassOf(Flowable),'NoneOrDirectDraw'),desc="A DirectDrawFlowable instance", _advancedUsage=1), 78 ddfStyle = AttrMapValue(NoneOr(isSubclassOf(PropertySet)),desc="A style for a ddfKlass or None", _advancedUsage=1), 79 ) 80 81 def __init__(self,**kw): 82 self._setKeywords(**kw) 83 self._setKeywords( 84 _text = 'Multi-Line\nString', 85 boxAnchor = 'c', 86 angle = 0, 87 x = 0, 88 y = 0, 89 dx = 0, 90 dy = 0, 91 topPadding = 0, 92 leftPadding = 0, 93 rightPadding = 0, 94 bottomPadding = 0, 95 boxStrokeWidth = 0.5, 96 boxStrokeColor = None, 97 boxTarget = 'normal', 98 strokeColor = None, 99 boxFillColor = None, 100 leading = None, 101 width = None, 102 maxWidth = None, 103 height = None, 104 fillColor = STATE_DEFAULTS['fillColor'], 105 fontName = STATE_DEFAULTS['fontName'], 106 fontSize = STATE_DEFAULTS['fontSize'], 107 strokeWidth = 0.1, 108 textAnchor = 'start', 109 visible = 1, 110 useAscentDescent = False, 111 ddf = DirectDrawFlowable, 112 ddfKlass = None, 113 ddfStyle = None, 114 ) 115 116 def setText(self, text): 117 """Set the text property. May contain embedded newline characters. 118 Called by the containing chart or axis.""" 119 self._text = text 120 121 122 def setOrigin(self, x, y): 123 """Set the origin. This would be the tick mark or bar top relative to 124 which it is defined. Called by the containing chart or axis.""" 125 self.x = x 126 self.y = y 127 128 129 def demo(self): 130 """This shows a label positioned with its top right corner 131 at the top centre of the drawing, and rotated 45 degrees.""" 132 133 d = Drawing(200, 100) 134 135 # mark the origin of the label 136 d.add(Circle(100,90, 5, fillColor=colors.green)) 137 138 lab = Label() 139 lab.setOrigin(100,90) 140 lab.boxAnchor = 'ne' 141 lab.angle = 45 142 lab.dx = 0 143 lab.dy = -20 144 lab.boxStrokeColor = colors.green 145 lab.setText('Another\nMulti-Line\nString') 146 d.add(lab) 147 148 return d 149 150 def _getBoxAnchor(self): 151 '''hook for allowing special box anchor effects''' 152 ba = self.boxAnchor 153 if ba in ('autox', 'autoy'): 154 angle = self.angle 155 na = (int((angle%360)/45.)*45)%360 156 if not (na % 90): # we have a right angle case 157 da = (angle - na) % 360 158 if abs(da)>5: 159 na = na + (da>0 and 45 or -45) 160 ba = _A2BA[ba[-1]][na] 161 return ba 162 163 def _getBaseLineRatio(self): 164 if self.useAscentDescent: 165 self._ascent, self._descent = getAscentDescent(self.fontName,self.fontSize) 166 self._baselineRatio = self._ascent/(self._ascent-self._descent) 167 else: 168 self._baselineRatio = 1/1.2 169 170 def _computeSizeEnd(self,objH): 171 self._height = self.height or (objH + self.topPadding + self.bottomPadding) 172 self._ewidth = (self._width-self.leftPadding-self.rightPadding) 173 self._eheight = (self._height-self.topPadding-self.bottomPadding) 174 boxAnchor = self._getBoxAnchor() 175 if boxAnchor in ['n','ne','nw']: 176 self._top = -self.topPadding 177 elif boxAnchor in ['s','sw','se']: 178 self._top = self._height-self.topPadding 179 else: 180 self._top = 0.5*self._eheight 181 self._bottom = self._top - self._eheight 182 183 if boxAnchor in ['ne','e','se']: 184 self._left = self.leftPadding - self._width 185 elif boxAnchor in ['nw','w','sw']: 186 self._left = self.leftPadding 187 else: 188 self._left = -self._ewidth*0.5 189 self._right = self._left+self._ewidth 190 191 def computeSize(self): 192 # the thing will draw in its own coordinate system 193 ddfKlass = getattr(self,'ddfKlass',None) 194 if not ddfKlass: 195 self._lineWidths = [] 196 self._lines = simpleSplit(self._text,self.fontName,self.fontSize,self.maxWidth) 197 if not self.width: 198 self._width = self.leftPadding+self.rightPadding 199 if self._lines: 200 self._lineWidths = [stringWidth(line,self.fontName,self.fontSize) for line in self._lines] 201 self._width += max(self._lineWidths) 202 else: 203 self._width = self.width 204 self._getBaseLineRatio() 205 if self.leading: 206 self._leading = self.leading 207 elif self.useAscentDescent: 208 self._leading = self._ascent - self._descent 209 else: 210 self._leading = self.fontSize*1.2 211 objH = self._leading*len(self._lines) 212 else: 213 if self.ddf is None: 214 raise RuntimeError('DirectDrawFlowable class is not available you need the rlextra package as well as reportlab') 215 sty = dict( 216 name='xlabel-generated', 217 fontName=self.fontName, 218 fontSize=self.fontSize, 219 fillColor=self.fillColor, 220 strokeColor=self.strokeColor, 221 ) 222 sty = self._style = (ddfStyle.clone if self.ddfStyle else ParagraphStyle)(**sty) 223 self._getBaseLineRatio() 224 if self.useAscentDescent: 225 sty.autoLeading = True 226 sty.leading = self._ascent - self._descent 227 else: 228 sty.leading = self.leading if self.leading else self.fontSize*1.2 229 self._leading = sty.leading 230 ta = self._getTextAnchor() 231 aW = self.maxWidth or 0x7fffffff 232 if ta!='start': 233 sty.alignment = TA_LEFT 234 obj = ddfKlass(self._text,style=sty) 235 _, objH = obj.wrap(aW,0x7fffffff) 236 aW = self.maxWidth or obj._width_max 237 sty.alignment = _ta2al[ta] 238 self._ddfObj = obj = ddfKlass(self._text,style=sty) 239 _, objH = obj.wrap(aW,0x7fffffff) 240 241 if not self.width: 242 self._width = self.leftPadding+self.rightPadding 243 self._width += obj._width_max 244 else: 245 self._width = self.width 246 self._computeSizeEnd(objH) 247 248 def _getTextAnchor(self): 249 '''This can be overridden to allow special effects''' 250 ta = self.textAnchor 251 if ta=='boxauto': ta = _BA2TA[self._getBoxAnchor()] 252 return ta 253 254 def _rawDraw(self): 255 _text = self._text 256 self._text = _text or '' 257 self.computeSize() 258 self._text = _text 259 g = Group() 260 g.translate(self.x + self.dx, self.y + self.dy) 261 g.rotate(self.angle) 262 263 ddfKlass = getattr(self,'ddfKlass',None) 264 if ddfKlass: 265 x = self._left 266 else: 267 y = self._top - self._leading*self._baselineRatio 268 textAnchor = self._getTextAnchor() 269 if textAnchor == 'start': 270 x = self._left 271 elif textAnchor == 'middle': 272 x = self._left + self._ewidth*0.5 273 else: 274 x = self._right 275 276 # paint box behind text just in case they 277 # fill it 278 if self.boxFillColor or (self.boxStrokeColor and self.boxStrokeWidth): 279 g.add(Rect( self._left-self.leftPadding, 280 self._bottom-self.bottomPadding, 281 self._width, 282 self._height, 283 strokeColor=self.boxStrokeColor, 284 strokeWidth=self.boxStrokeWidth, 285 fillColor=self.boxFillColor) 286 ) 287 288 if ddfKlass: 289 g1 = Group() 290 g1.translate(x,self._top-self._eheight) 291 g1.add(self.ddf(self._ddfObj)) 292 g.add(g1) 293 else: 294 fillColor, fontName, fontSize = self.fillColor, self.fontName, self.fontSize 295 strokeColor, strokeWidth, leading = self.strokeColor, self.strokeWidth, self._leading 296 svgAttrs=getattr(self,'_svgAttrs',{}) 297 if strokeColor: 298 for line in self._lines: 299 s = _text2Path(line, x, y, fontName, fontSize, textAnchor) 300 s.fillColor = fillColor 301 s.strokeColor = strokeColor 302 s.strokeWidth = strokeWidth 303 g.add(s) 304 y -= leading 305 else: 306 for line in self._lines: 307 s = String(x, y, line, _svgAttrs=svgAttrs) 308 s.textAnchor = textAnchor 309 s.fontName = fontName 310 s.fontSize = fontSize 311 s.fillColor = fillColor 312 g.add(s) 313 y -= leading 314 315 return g 316 317 def draw(self): 318 customDrawChanger = getattr(self,'customDrawChanger',None) 319 if customDrawChanger: 320 customDrawChanger(True,self) 321 try: 322 return self._rawDraw() 323 finally: 324 customDrawChanger(False,self) 325 else: 326 return self._rawDraw() 327 328class LabelDecorator: 329 _attrMap = AttrMap( 330 x = AttrMapValue(isNumberOrNone,desc=''), 331 y = AttrMapValue(isNumberOrNone,desc=''), 332 dx = AttrMapValue(isNumberOrNone,desc=''), 333 dy = AttrMapValue(isNumberOrNone,desc=''), 334 angle = AttrMapValue(isNumberOrNone,desc=''), 335 boxAnchor = AttrMapValue(isBoxAnchor,desc=''), 336 boxStrokeColor = AttrMapValue(isColorOrNone,desc=''), 337 boxStrokeWidth = AttrMapValue(isNumberOrNone,desc=''), 338 boxFillColor = AttrMapValue(isColorOrNone,desc=''), 339 fillColor = AttrMapValue(isColorOrNone,desc=''), 340 strokeColor = AttrMapValue(isColorOrNone,desc=''), 341 strokeWidth = AttrMapValue(isNumberOrNone),desc='', 342 fontName = AttrMapValue(isNoneOrString,desc=''), 343 fontSize = AttrMapValue(isNumberOrNone,desc=''), 344 leading = AttrMapValue(isNumberOrNone,desc=''), 345 width = AttrMapValue(isNumberOrNone,desc=''), 346 maxWidth = AttrMapValue(isNumberOrNone,desc=''), 347 height = AttrMapValue(isNumberOrNone,desc=''), 348 textAnchor = AttrMapValue(isTextAnchor,desc=''), 349 visible = AttrMapValue(isBoolean,desc="True if the label is to be drawn"), 350 ) 351 352 def __init__(self): 353 self.textAnchor = 'start' 354 self.boxAnchor = 'w' 355 for a in self._attrMap.keys(): 356 if not hasattr(self,a): setattr(self,a,None) 357 358 def decorate(self,l,L): 359 chart,g,rowNo,colNo,x,y,width,height,x00,y00,x0,y0 = l._callOutInfo 360 L.setText(chart.categoryAxis.categoryNames[colNo]) 361 g.add(L) 362 363 def __call__(self,l): 364 from copy import deepcopy 365 L = Label() 366 for a,v in self.__dict__.items(): 367 if v is None: v = getattr(l,a,None) 368 setattr(L,a,v) 369 self.decorate(l,L) 370 371isOffsetMode=OneOf('high','low','bar','axis') 372class LabelOffset(PropHolder): 373 _attrMap = AttrMap( 374 posMode = AttrMapValue(isOffsetMode,desc="Where to base +ve offset"), 375 pos = AttrMapValue(isNumber,desc='Value for positive elements'), 376 negMode = AttrMapValue(isOffsetMode,desc="Where to base -ve offset"), 377 neg = AttrMapValue(isNumber,desc='Value for negative elements'), 378 ) 379 def __init__(self): 380 self.posMode=self.negMode='axis' 381 self.pos = self.neg = 0 382 383 def _getValue(self, chart, val): 384 flipXY = chart._flipXY 385 A = chart.categoryAxis 386 jA = A.joinAxis 387 if val>=0: 388 mode = self.posMode 389 delta = self.pos 390 else: 391 mode = self.negMode 392 delta = self.neg 393 if flipXY: 394 v = A._x 395 else: 396 v = A._y 397 if jA: 398 if flipXY: 399 _v = jA._x 400 else: 401 _v = jA._y 402 if mode=='high': 403 v = _v + jA._length 404 elif mode=='low': 405 v = _v 406 elif mode=='bar': 407 v = _v+val 408 return v+delta 409 410NoneOrInstanceOfLabelOffset=NoneOr(isInstanceOf(LabelOffset)) 411 412class PMVLabel(Label): 413 _attrMap = AttrMap( 414 BASE=Label, 415 ) 416 417 def __init__(self, **kwds): 418 Label.__init__(self, **kwds) 419 self._pmv = 0 420 421 def _getBoxAnchor(self): 422 a = Label._getBoxAnchor(self) 423 if self._pmv<0: a = {'nw':'se','n':'s','ne':'sw','w':'e','c':'c','e':'w','sw':'ne','s':'n','se':'nw'}[a] 424 return a 425 426 def _getTextAnchor(self): 427 a = Label._getTextAnchor(self) 428 if self._pmv<0: a = {'start':'end', 'middle':'middle', 'end':'start'}[a] 429 return a 430 431class BarChartLabel(PMVLabel): 432 """ 433 An extended Label allowing for nudging, lines visibility etc 434 """ 435 _attrMap = AttrMap( 436 BASE=PMVLabel, 437 lineStrokeWidth = AttrMapValue(isNumberOrNone, desc="Non-zero for a drawn line"), 438 lineStrokeColor = AttrMapValue(isColorOrNone, desc="Color for a drawn line"), 439 fixedEnd = AttrMapValue(NoneOrInstanceOfLabelOffset, desc="None or fixed draw ends +/-"), 440 fixedStart = AttrMapValue(NoneOrInstanceOfLabelOffset, desc="None or fixed draw starts +/-"), 441 nudge = AttrMapValue(isNumber, desc="Non-zero sign dependent nudge"), 442 boxTarget = AttrMapValue(OneOf('normal','anti','lo','hi','mid'),desc="one of ('normal','anti','lo','hi','mid')"), 443 ) 444 445 def __init__(self, **kwds): 446 PMVLabel.__init__(self, **kwds) 447 self.lineStrokeWidth = 0 448 self.lineStrokeColor = None 449 self.fixedStart = self.fixedEnd = None 450 self.nudge = 0 451 452class NA_Label(BarChartLabel): 453 """ 454 An extended Label allowing for nudging, lines visibility etc 455 """ 456 _attrMap = AttrMap( 457 BASE=BarChartLabel, 458 text = AttrMapValue(isNoneOrString, desc="Text to be used for N/A values"), 459 ) 460 def __init__(self): 461 BarChartLabel.__init__(self) 462 self.text = 'n/a' 463NoneOrInstanceOfNA_Label=NoneOr(isInstanceOf(NA_Label)) 464 465from reportlab.graphics.charts.utils import CustomDrawChanger 466class RedNegativeChanger(CustomDrawChanger): 467 def __init__(self,fillColor=colors.red): 468 CustomDrawChanger.__init__(self) 469 self.fillColor = fillColor 470 def _changer(self,obj): 471 R = {} 472 if obj._text.startswith('-'): 473 R['fillColor'] = obj.fillColor 474 obj.fillColor = self.fillColor 475 return R 476 477class XLabel(Label): 478 '''like label but uses XPreFormatted/Paragraph to draw the _text''' 479 _attrMap = AttrMap(BASE=Label, 480 ) 481 def __init__(self,*args,**kwds): 482 Label.__init__(self,*args,**kwds) 483 self.ddfKlass = kwds.pop('flowableClass',XPreformatted) 484 self.ddf = kwds.pop('directDrawClass',self.ddf) 485 486 if False: 487 def __init__(self,*args,**kwds): 488 self._flowableClass = kwds.pop('flowableClass',XPreformatted) 489 ddf = kwds.pop('directDrawClass',DirectDrawFlowable) 490 if ddf is None: 491 raise RuntimeError('DirectDrawFlowable class is not available you need the rlextra package as well as reportlab') 492 self._ddf = ddf 493 Label.__init__(self,*args,**kwds) 494 def computeSize(self): 495 # the thing will draw in its own coordinate system 496 self._lineWidths = [] 497 sty = self._style = ParagraphStyle('xlabel-generated', 498 fontName=self.fontName, 499 fontSize=self.fontSize, 500 fillColor=self.fillColor, 501 strokeColor=self.strokeColor, 502 ) 503 self._getBaseLineRatio() 504 if self.useAscentDescent: 505 sty.autoLeading = True 506 sty.leading = self._ascent - self._descent 507 else: 508 sty.leading = self.leading if self.leading else self.fontSize*1.2 509 self._leading = sty.leading 510 ta = self._getTextAnchor() 511 aW = self.maxWidth or 0x7fffffff 512 if ta!='start': 513 sty.alignment = TA_LEFT 514 obj = self._flowableClass(self._text,style=sty) 515 _, objH = obj.wrap(aW,0x7fffffff) 516 aW = self.maxWidth or obj._width_max 517 sty.alignment = _ta2al[ta] 518 self._obj = obj = self._flowableClass(self._text,style=sty) 519 _, objH = obj.wrap(aW,0x7fffffff) 520 521 if not self.width: 522 self._width = self.leftPadding+self.rightPadding 523 self._width += self._obj._width_max 524 else: 525 self._width = self.width 526 self._computeSizeEnd(objH) 527 528 def _rawDraw(self): 529 _text = self._text 530 self._text = _text or '' 531 self.computeSize() 532 self._text = _text 533 g = Group() 534 g.translate(self.x + self.dx, self.y + self.dy) 535 g.rotate(self.angle) 536 537 x = self._left 538 539 # paint box behind text just in case they 540 # fill it 541 if self.boxFillColor or (self.boxStrokeColor and self.boxStrokeWidth): 542 g.add(Rect( self._left-self.leftPadding, 543 self._bottom-self.bottomPadding, 544 self._width, 545 self._height, 546 strokeColor=self.boxStrokeColor, 547 strokeWidth=self.boxStrokeWidth, 548 fillColor=self.boxFillColor) 549 ) 550 g1 = Group() 551 g1.translate(x,self._top-self._eheight) 552 g1.add(self._ddf(self._obj)) 553 g.add(g1) 554 return g 555