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