src/reportlab/graphics/widgets/grids.py
author robin <robin@reportlab.com>
Tue, 07 Mar 2017 10:00:34 +0000
changeset 4330 617ffa6bbdc8
parent 4252 fe660f227cac
child 4370 823a8c33ce43
permissions -rw-r--r--
changes for release 3.4.0

#Copyright ReportLab Europe Ltd. 2000-2017
#see license.txt for license details
#history http://www.reportlab.co.uk/cgi-bin/viewcvs.cgi/public/reportlab/trunk/reportlab/graphics/widgets/grids.py
__version__='3.3.0'

from reportlab.lib import colors
from reportlab.lib.validators import isNumber, isColorOrNone, isBoolean, isListOfNumbers, OneOf, isListOfColors, isNumberOrNone
from reportlab.lib.attrmap import AttrMap, AttrMapValue
from reportlab.graphics.shapes import Drawing, Group, Line, Rect, LineShape, definePath, EmptyClipPath
from reportlab.graphics.widgetbase import Widget

def frange(start, end=None, inc=None):
    "A range function, that does accept float increments..."

    if end == None:
        end = start + 0.0
        start = 0.0

    if inc == None:
        inc = 1.0

    L = []
    end = end - inc*0.0001  #to avoid numrical problems
    while 1:
        next = start + len(L) * inc
        if inc > 0 and next >= end:
            break
        elif inc < 0 and next <= end:
            break
        L.append(next)

    return L


def makeDistancesList(list):
    """Returns a list of distances between adjacent numbers in some input list.

    E.g. [1, 1, 2, 3, 5, 7] -> [0, 1, 1, 2, 2]
    """

    d = []
    for i in range(len(list[:-1])):
        d.append(list[i+1] - list[i])

    return d


class Grid(Widget):
    """This makes a rectangular grid of equidistant stripes.

    The grid contains an outer border rectangle, and stripes
    inside which can be drawn with lines and/or as solid tiles.
    The drawing order is: outer rectangle, then lines and tiles.

    The stripes' width is indicated as 'delta'. The sequence of
    stripes can have an offset named 'delta0'. Both values need
    to be positive!
    """

    _attrMap = AttrMap(
        x = AttrMapValue(isNumber, desc="The grid's lower-left x position."),
        y = AttrMapValue(isNumber, desc="The grid's lower-left y position."),
        width = AttrMapValue(isNumber, desc="The grid's width."),
        height = AttrMapValue(isNumber, desc="The grid's height."),
        orientation = AttrMapValue(OneOf(('vertical', 'horizontal')),
            desc='Determines if stripes are vertical or horizontal.'),
        useLines = AttrMapValue(OneOf((0, 1)),
            desc='Determines if stripes are drawn with lines.'),
        useRects = AttrMapValue(OneOf((0, 1)),
            desc='Determines if stripes are drawn with solid rectangles.'),
        delta = AttrMapValue(isNumber,
            desc='Determines the width/height of the stripes.'),
        delta0 = AttrMapValue(isNumber,
            desc='Determines the stripes initial width/height offset.'),
        deltaSteps = AttrMapValue(isListOfNumbers,
            desc='List of deltas to be used cyclically.'),
        stripeColors = AttrMapValue(isListOfColors,
            desc='Colors applied cyclically in the right or upper direction.'),
        fillColor = AttrMapValue(isColorOrNone,
            desc='Background color for entire rectangle.'),
        strokeColor = AttrMapValue(isColorOrNone,
            desc='Color used for lines.'),
        strokeWidth = AttrMapValue(isNumber,
            desc='Width used for lines.'),
        rectStrokeColor = AttrMapValue(isColorOrNone, desc='Color for outer rect stroke.'),
        rectStrokeWidth = AttrMapValue(isNumberOrNone, desc='Width for outer rect stroke.'),
        )

    def __init__(self):
        self.x = 0
        self.y = 0
        self.width = 100
        self.height = 100
        self.orientation = 'vertical'
        self.useLines = 0
        self.useRects = 1
        self.delta = 20
        self.delta0 = 0
        self.deltaSteps = []
        self.fillColor = colors.white
        self.stripeColors = [colors.red, colors.green, colors.blue]
        self.strokeColor = colors.black
        self.strokeWidth = 2


    def demo(self):
        D = Drawing(100, 100)

        g = Grid()
        D.add(g)

        return D

    def makeOuterRect(self):
        strokeColor = getattr(self,'rectStrokeColor',self.strokeColor)
        strokeWidth = getattr(self,'rectStrokeWidth',self.strokeWidth)
        if self.fillColor or (strokeColor and strokeWidth):
            rect = Rect(self.x, self.y, self.width, self.height)
            rect.fillColor = self.fillColor
            rect.strokeColor = strokeColor
            rect.strokeWidth = strokeWidth
            return rect
        else:
            return None

    def makeLinePosList(self, start, isX=0):
        "Returns a list of positions where to place lines."

        w, h = self.width, self.height
        if isX:
            length = w
        else:
            length = h
        if self.deltaSteps:
            r = [start + self.delta0]
            i = 0
            while 1:
                if r[-1] > start + length:
                    del r[-1]
                    break
                r.append(r[-1] + self.deltaSteps[i % len(self.deltaSteps)])
                i = i + 1
        else:
            r = frange(start + self.delta0, start + length, self.delta)

        r.append(start + length)
        if self.delta0 != 0:
            r.insert(0, start)
        #print 'Grid.makeLinePosList() -> %s' % r
        return r


    def makeInnerLines(self):
        # inner grid lines
        group = Group()

        w, h = self.width, self.height

        if self.useLines == 1:
            if self.orientation == 'vertical':
                r = self.makeLinePosList(self.x, isX=1)
                for x in r:
                    line = Line(x, self.y, x, self.y + h)
                    line.strokeColor = self.strokeColor
                    line.strokeWidth = self.strokeWidth
                    group.add(line)
            elif self.orientation == 'horizontal':
                r = self.makeLinePosList(self.y, isX=0)
                for y in r:
                    line = Line(self.x, y, self.x + w, y)
                    line.strokeColor = self.strokeColor
                    line.strokeWidth = self.strokeWidth
                    group.add(line)

        return group


    def makeInnerTiles(self):
        # inner grid lines
        group = Group()

        w, h = self.width, self.height

        # inner grid stripes (solid rectangles)
        if self.useRects == 1:
            cols = self.stripeColors

            if self.orientation == 'vertical':
                r = self.makeLinePosList(self.x, isX=1)
            elif self.orientation == 'horizontal':
                r = self.makeLinePosList(self.y, isX=0)

            dist = makeDistancesList(r)

            i = 0
            for j in range(len(dist)):
                if self.orientation == 'vertical':
                    x = r[j]
                    stripe = Rect(x, self.y, dist[j], h)
                elif self.orientation == 'horizontal':
                    y = r[j]
                    stripe = Rect(self.x, y, w, dist[j])
                stripe.fillColor = cols[i % len(cols)]
                stripe.strokeColor = None
                group.add(stripe)
                i = i + 1

        return group


    def draw(self):
        # general widget bits
        group = Group()

        group.add(self.makeOuterRect())
        group.add(self.makeInnerTiles())
        group.add(self.makeInnerLines(),name='_gridLines')

        return group


class DoubleGrid(Widget):
    """This combines two ordinary Grid objects orthogonal to each other.
    """

    _attrMap = AttrMap(
        x = AttrMapValue(isNumber, desc="The grid's lower-left x position."),
        y = AttrMapValue(isNumber, desc="The grid's lower-left y position."),
        width = AttrMapValue(isNumber, desc="The grid's width."),
        height = AttrMapValue(isNumber, desc="The grid's height."),
        grid0 = AttrMapValue(None, desc="The first grid component."),
        grid1 = AttrMapValue(None, desc="The second grid component."),
        )

    def __init__(self):
        self.x = 0
        self.y = 0
        self.width = 100
        self.height = 100

        g0 = Grid()
        g0.x = self.x
        g0.y = self.y
        g0.width = self.width
        g0.height = self.height
        g0.orientation = 'vertical'
        g0.useLines = 1
        g0.useRects = 0
        g0.delta = 20
        g0.delta0 = 0
        g0.deltaSteps = []
        g0.fillColor = colors.white
        g0.stripeColors = [colors.red, colors.green, colors.blue]
        g0.strokeColor = colors.black
        g0.strokeWidth = 1

        g1 = Grid()
        g1.x = self.x
        g1.y = self.y
        g1.width = self.width
        g1.height = self.height
        g1.orientation = 'horizontal'
        g1.useLines = 1
        g1.useRects = 0
        g1.delta = 20
        g1.delta0 = 0
        g1.deltaSteps = []
        g1.fillColor = colors.white
        g1.stripeColors = [colors.red, colors.green, colors.blue]
        g1.strokeColor = colors.black
        g1.strokeWidth = 1

        self.grid0 = g0
        self.grid1 = g1


##    # This gives an AttributeError:
##    #   DoubleGrid instance has no attribute 'grid0'
##    def __setattr__(self, name, value):
##        if name in ('x', 'y', 'width', 'height'):
##            setattr(self.grid0, name, value)
##            setattr(self.grid1, name, value)


    def demo(self):
        D = Drawing(100, 100)
        g = DoubleGrid()
        D.add(g)
        return D


    def draw(self):
        group = Group()
        g0, g1 = self.grid0, self.grid1
        # Order groups to make sure both v and h lines
        # are visible (works only when there is only
        # one kind of stripes, v or h).
        G = g0.useRects == 1 and g1.useRects == 0 and (g0,g1) or (g1,g0)
        for g in G:
            group.add(g.makeOuterRect())
        for g in G:
            group.add(g.makeInnerTiles())
            group.add(g.makeInnerLines(),name='_gridLines')

        return group


class ShadedRect(Widget):
    """This makes a rectangle with shaded colors between two colors.

    Colors are interpolated linearly between 'fillColorStart'
    and 'fillColorEnd', both of which appear at the margins.
    If 'numShades' is set to one, though, only 'fillColorStart'
    is used.
    """

    _attrMap = AttrMap(
        x = AttrMapValue(isNumber, desc="The grid's lower-left x position."),
        y = AttrMapValue(isNumber, desc="The grid's lower-left y position."),
        width = AttrMapValue(isNumber, desc="The grid's width."),
        height = AttrMapValue(isNumber, desc="The grid's height."),
        orientation = AttrMapValue(OneOf(('vertical', 'horizontal')), desc='Determines if stripes are vertical or horizontal.'),
        numShades = AttrMapValue(isNumber, desc='The number of interpolating colors.'),
        fillColorStart = AttrMapValue(isColorOrNone, desc='Start value of the color shade.'),
        fillColorEnd = AttrMapValue(isColorOrNone, desc='End value of the color shade.'),
        strokeColor = AttrMapValue(isColorOrNone, desc='Color used for border line.'),
        strokeWidth = AttrMapValue(isNumber, desc='Width used for lines.'),
        cylinderMode = AttrMapValue(isBoolean, desc='True if shading reverses in middle.'),
        )

    def __init__(self,**kw):
        self.x = 0
        self.y = 0
        self.width = 100
        self.height = 100
        self.orientation = 'vertical'
        self.numShades = 20
        self.fillColorStart = colors.pink
        self.fillColorEnd = colors.black
        self.strokeColor = colors.black
        self.strokeWidth = 2
        self.cylinderMode = 0
        self.setProperties(kw)

    def demo(self):
        D = Drawing(100, 100)
        g = ShadedRect()
        D.add(g)

        return D

    def _flipRectCorners(self):
        "Flip rectangle's corners if width or height is negative."
        x, y, width, height, fillColorStart, fillColorEnd = self.x, self.y, self.width, self.height, self.fillColorStart, self.fillColorEnd
        if width < 0 and height > 0:
            x = x + width
            width = -width
            if self.orientation=='vertical': fillColorStart, fillColorEnd = fillColorEnd, fillColorStart
        elif height<0 and width>0:
            y = y + height
            height = -height
            if self.orientation=='horizontal': fillColorStart, fillColorEnd = fillColorEnd, fillColorStart
        elif height < 0 and height < 0:
            x = x + width
            width = -width
            y = y + height
            height = -height
        return x, y, width, height, fillColorStart, fillColorEnd

    def draw(self):
        # general widget bits
        group = Group()
        x, y, w, h, c0, c1 = self._flipRectCorners()
        numShades = self.numShades
        if self.cylinderMode:
            if not numShades%2: numShades = numShades+1
            halfNumShades = int((numShades-1)/2) + 1
        num = float(numShades) # must make it float!
        vertical = self.orientation == 'vertical'
        if vertical:
            if numShades == 1:
                V = [x]
            else:
                V = frange(x, x + w, w/num)
        else:
            if numShades == 1:
                V = [y]
            else:
                V = frange(y, y + h, h/num)

        for v in V:
            stripe = vertical and Rect(v, y, w/num, h) or Rect(x, v, w, h/num)
            if self.cylinderMode:
                if V.index(v)>=halfNumShades:
                    col = colors.linearlyInterpolatedColor(c1,c0,V[halfNumShades],V[-1], v)
                else:
                    col = colors.linearlyInterpolatedColor(c0,c1,V[0],V[halfNumShades], v)
            else:
                col = colors.linearlyInterpolatedColor(c0,c1,V[0],V[-1], v)
            stripe.fillColor = col
            stripe.strokeColor = col
            stripe.strokeWidth = 1
            group.add(stripe)
        if self.strokeColor and self.strokeWidth>=0:
            rect = Rect(x, y, w, h)
            rect.strokeColor = self.strokeColor
            rect.strokeWidth = self.strokeWidth
            rect.fillColor = None
            group.add(rect)
        return group


def colorRange(c0, c1, n):
    "Return a range of intermediate colors between c0 and c1"
    if n==1: return [c0]

    C = []
    if n>1:
        lim = n-1
        for i in range(n):
            C.append(colors.linearlyInterpolatedColor(c0,c1,0,lim, i))
    return C


def centroid(P):
    '''compute average point of a set of points'''
    cx = 0
    cy = 0
    for x,y in P:
        cx+=x
        cy+=y
    n = float(len(P))
    return cx/n, cy/n

def rotatedEnclosingRect(P, angle, rect):
    '''
    given P a sequence P of x,y coordinate pairs and an angle in degrees
    find the centroid of P and the axis at angle theta through it
    find the extreme points of P wrt axis parallel distance and axis
    orthogonal distance. Then compute the least rectangle that will still
    enclose P when rotated by angle.

    The class R
    '''
    from math import pi, cos, sin, tan
    x0, y0 = centroid(P)
    theta = (angle/180.)*pi
    s,c=sin(theta),cos(theta)
    def parallelAxisDist(xy,s=s,c=c,x0=x0,y0=y0):
        x,y = xy
        return (s*(y-y0)+c*(x-x0))
    def orthogonalAxisDist(xy,s=s,c=c,x0=x0,y0=y0):
        x,y = xy
        return (c*(y-y0)+s*(x-x0))
    L = list(map(parallelAxisDist,P))
    L.sort()
    a0, a1 = L[0], L[-1]
    L = list(map(orthogonalAxisDist,P))
    L.sort()
    b0, b1 = L[0], L[-1]
    rect.x, rect.width = a0, a1-a0
    rect.y, rect.height = b0, b1-b0
    g = Group(transform=(c,s,-s,c,x0,y0))
    g.add(rect)
    return g

class ShadedPolygon(Widget,LineShape):
    _attrMap = AttrMap(BASE=LineShape,
        angle = AttrMapValue(isNumber,desc="Shading angle"),
        fillColorStart = AttrMapValue(isColorOrNone),
        fillColorEnd = AttrMapValue(isColorOrNone),
        numShades = AttrMapValue(isNumber, desc='The number of interpolating colors.'),
        cylinderMode = AttrMapValue(isBoolean, desc='True if shading reverses in middle.'),
        points = AttrMapValue(isListOfNumbers),
        )

    def __init__(self,**kw):
        self.angle = 90
        self.fillColorStart = colors.red
        self.fillColorEnd = colors.green
        self.cylinderMode = 0
        self.numShades = 50
        self.points = [-1,-1,2,2,3,-1]
        LineShape.__init__(self,kw)

    def draw(self):
        P = self.points
        P = list(map(lambda i, P=P:(P[i],P[i+1]),range(0,len(P),2)))
        path = definePath([('moveTo',)+P[0]]+[('lineTo',)+x for x in P[1:]]+['closePath'],
            fillColor=None, strokeColor=None)
        path.isClipPath = 1
        g = Group()
        g.add(path)
        angle = self.angle
        orientation = 'vertical'
        if angle==180:
            angle = 0
        elif angle in (90,270):
            orientation ='horizontal'
            angle = 0
        rect = ShadedRect(strokeWidth=0,strokeColor=None,orientation=orientation)
        for k in 'fillColorStart', 'fillColorEnd', 'numShades', 'cylinderMode':
            setattr(rect,k,getattr(self,k))
        g.add(rotatedEnclosingRect(P, angle, rect))
        g.add(EmptyClipPath)
        path = path.copy()
        path.isClipPath = 0
        path.strokeColor = self.strokeColor
        path.strokeWidth = self.strokeWidth
        g.add(path)
        return g

if __name__=='__main__': #noruntests
    from reportlab.lib.colors import blue
    from reportlab.graphics.shapes import Drawing
    angle=45
    D = Drawing(120,120)
    D.add(ShadedPolygon(points=(10,10,60,60,110,10),strokeColor=None,strokeWidth=1,angle=90,numShades=50,cylinderMode=0))
    D.save(formats=['gif'],fnRoot='shobj',outDir='/tmp')