creating EPS with Python

Will Stuyvesant hwlgw at hotmail.com
Tue Dec 23 11:34:25 EST 2003


<copied from a thread in comp.lang.postscript>

> [Jamie]
>    Now all I need to do is create vector eps files. Can you tell me
> where I should start for that?

If you don't know yet how to create .eps manually, search Google for
"Bluebook EPS".  It has some nice examples.  But from an earlier
remark by you, something like "the eps spec being to detailed" I guess
you are looking for a high level way of creating eps.  I could not
find such a thing to my liking, so at the moment I am working on a
Python module for this, the idea is to use Python to create .eps
files.  What I have now is below, it is a Python module that sends
test EPS to stdout when run standalone, if you call it pyps.py (like I
do) and then do

python pyps.py > test.eps

you get an eps file that shows some things it can do.  I am not a
professional programmer so there are probably bugs etc., if a good
Python programmer is reading this then contact me if you'd like to
help with the project!

----- pyps.py -----

'''

This file (pyps.py) contains a Python module for generating .eps
files (Postscript with a BoundingBox) that display diagrams.


Programmer notes:
-----------------

The AutoPicture is a container class, you create an instance, add
figures to it and then you can call its epsText method that returns
the eps code: it calculates an EPS BoundingBox.  See the global test
methods for example usage.

The basic classes to inherit from to create figures are StrokeFill
and TranslatedStrokeFill.  Override their epsCode method with a
method that returns the epscode for the figure you want.

CLASSES
    AutoPicture
    StrokeFill
        Box
            GridBox
        Line
        TranslatedStrokeFill
            Arrow
                OpenArrow
            Circle
    Text

Classes in pyps and children of classes in pyps are expected to have
a method epsText() that returns Postscript code for a while figure,
including code for (translations and) rotations and scaling.
Classes should have a methods epsCode() that returns Postscript code
for a figure without (translation,) rotation or scale code.  The
epsCode method is called by the epsText method in classes StrokeFill
and TranslatedStrokeFill.

Class StrokeFill does NOT do a Postscript translation.  Used for
Line and Box as parent class.  Class TranslatedStrokeFill does do a
Postscript translation to (self.x, self.y).  Used for Circle and
OpenArrow as parent class.

'''

import math
import string
import sys

goldenratio = math.sqrt(5)/5.0  # about 0.45

##
# Mostly here for debugging purposes.
# Used in test() to show the size of the generated picture.
##
def log(msg):
    sys.stderr.write('\n'+msg+'\n')



###
# The Text class does not inherit from StrokeFill because it is
# incompatible: sometimes text is printed with a Postscript "show"
# command instead of a "fill" or "stroke".
###
class Text:
    def __init__(self, x=20, y=20, gray=0, linewidth=1, linegray=0,
            angle=0.0, text='Spam spam spam', scalefont=6,
            font='Times-Roman', xscale=1, yscale=1, charpath=0):
        self.x = x
        self.y = y
        self.gray = gray
        self.linegray = linegray
        self.linewidth = linewidth
        self.angle = angle
        self.text = text
        self.scalefont = scalefont
        self.font = font
        self.xscale = xscale
        self.yscale = yscale
        self.charpath = charpath
        lw = scalefont * goldenratio        # estimate of letterwidth
        textlength = len(text) * lw 
        x2 = x + lw + math.cos(math.radians(
                angle)) * textlength * xscale
        y2 = y + scalefont + math.sin(math.radians(
                angle)) * textlength * yscale
        # elw for extra letterwidth (of first letter, because of
        # rotation)
        elw = lw * 2 * math.sin(math.radians(angle))
        minX, minY = min(x, x2)-elw, min(y, y2)-lw*goldenratio
        maxX, maxY = max(x, x2), max(y+lw*2, y2)
        self.minx, self.miny = minX-linewidth, minY-linewidth
        self.maxx, self.maxy = maxX+linewidth, maxY+linewidth
    def epsText(self):
        # content pattern
        resultStr = '''
gsave
'''
        resultStr = resultStr + '''\
/%(font)s findfont
%(scalefont)s scalefont
setfont
''' 
        if self.angle or ( 
            int(self.xscale) != 1 or int(self.yscale) !=1 ):
            resultStr = resultStr + '''\
%(x)s %(y)s translate
'''
            if self.angle:
                resultStr = resultStr + '''\
%(angle)s rotate
'''
            if self.xscale != 1 or self.yscale !=1:
                resultStr = resultStr + '''\
%(xscale)s %(yscale)s scale
'''
            resultStr = resultStr + '''\
newpath
0 0 moveto
'''
        else:
            resultStr = resultStr + '''\
newpath
%(x)s %(y)s moveto
'''
        if self.charpath:
            resultStr = resultStr + '''\
(%(text)s) true charpath 
'''
        else:
            resultStr = resultStr + '''\
(%(text)s) show 
'''
        if self.gray and self.linewidth:
            resultStr = resultStr + '''\
gsave
%(gray)s setgray fill
grestore
'''
        elif self.gray:
            resultStr = resultStr + '''%(gray)s setgray fill\n'''
        if self.linewidth:
            # This should not be executed when charpath!=0, because
            # then it will do a Postscript 'show' and it does not
            # need a stroke anymore.
            if self.charpath != 0:
                resultStr = resultStr + '%(linewidth)s' 
                resultStr = resultStr + ' setlinewidth\n'
                if self.linegray:
                    resultStr = resultStr + '%(linegray)s' 
                    resultStr = resultStr + ' setgray\n'
                resultStr = resultStr + 'stroke\n'
        resultStr = resultStr + 'grestore\n'
        return resultStr % self.__dict__



##
# This is an abstract class.
# It is expected that epsCode will be overriden.
# @param dash Tuple.  Last element is offset.  The other elements
# are used in a Postscript setdash command for defining the pattern.
##
class StrokeFill:
    def __init__(self, x=0, y=0, gray=0, linewidth=1, linegray=0,
            angle=0.0, xscale=1.0, yscale=1.0, dash=None):
        self.x = x
        self.y = y
        self.gray = gray
        self.linegray = linegray
        self.linewidth = linewidth
        self.angle = angle
        self.xscale = xscale
        self.yscale = yscale
        self.dash = dash
        # These should be overriden in children of StrokeFill!
        self.minx, self.miny = 1000000, 1000000
        self.maxx, self.maxy = -1000000, -1000000
    ##
    # Override this: return a string of Postscript code.
    # In that string you make use of attributes in self.__dict__
    # like %(x)s
    def epsCode(self): return '\n'
    def epsText(self):
        # content pattern
        addSaveRestore = ( self.gray or self.angle or 
                 int(self.xscale != 1) or int(self.yscale != 1) or
                 self.linegray
        )
        resultStr = '\n'
        if addSaveRestore:
            resultStr = '''
gsave
'''
        if self.angle:
            resultStr = resultStr + '''\
%(angle)s rotate
'''
        if self.xscale != 1 or self.yscale !=1:
            resultStr = resultStr + '''\
%(xscale)s %(yscale)s scale
'''
        smallCode = self.epsCode()
        if smallCode: resultStr = resultStr + smallCode 
        if self.gray:
            resultStr = resultStr + '''\
gsave
%(gray)s setgray fill
grestore
'''
        if self.linewidth:
            resultStr = resultStr + '%(linewidth)s' 
            resultStr = resultStr + ' setlinewidth\n'
            if self.linegray:
                resultStr = resultStr + '%(linegray)s' 
                resultStr = resultStr + ' setgray\n'
            if self.dash:
                dashParams = '['
                i = 0
                patternList = self.dash[:-1]
                for p in patternList:
                    dashParams = dashParams + repr(p) 
                    if i < len(patternList)-1:
                        dashParams = dashParams + ' ' 
                    i = i + 1
                dashParams = dashParams + '] ' 
                dashParams = dashParams + repr(self.dash[-1])
                resultStr = resultStr + dashParams + ' setdash\n'
            resultStr = resultStr + 'stroke\n'
            if self.dash:
                resultStr = resultStr + '[] 0 setdash\n'
        if addSaveRestore: 
            resultStr = resultStr + 'grestore\n'
        return resultStr % self.__dict__






##
# __init__ constructor parameters:
# (x, y)    where from
# (x2, y2)  where to
# linegray  color for lines
# linewidth width of lines
##
class Line(StrokeFill):
    def __init__(self, x=20, y=20, gray=0, linewidth=1, linegray=0,
            angle=0.0, x2=100, y2=100, dash=None):
        StrokeFill.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                dash=dash)
        self.x2, self.y2 = x2, y2
        minX, minY = min(x, x2), min(y, y2)
        maxX, maxY = max(x, x2), max(y, y2)
        self.minx, self.miny = minX-linewidth, minY-linewidth
        self.maxx, self.maxy = maxX+linewidth, maxY+linewidth
    def epsCode(self):
        # Starting with a newline because there will not be a gsave
        # around a Line
        return '''
newpath
%(x)s  %(y)s moveto
%(x2)s  %(y2)s lineto
''' 



class Box(StrokeFill):
    ##
    # (x,y) is the lower left corner of the box, w is width, h is
    # height.
    # Parameters w and h have to be positive numbers.
    def __init__(self, x=0, y=0, linewidth=1, linegray=0, gray=0,
            angle=0.0, w=50, h=None, dash=None):
        StrokeFill.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                dash=dash)
        self.x = x
        self.y = y        
        if not h: h = w * goldenratio
        self.w = w
        self.h = h
        self.x2 = x+w
        self.y2 = y+h
        # The -linewidth and +linewidth are sometimes a little too
        # much when a box is in a corner, but tested effect looks
        # good.
        self.minx, self.miny = x-linewidth, y-linewidth
        self.maxx, self.maxy = self.x2+linewidth, self.y2+linewidth
    def epsCode(self):
        return '''\
newpath
%(x)s  %(y)s moveto
%(x2)s  %(y)s lineto
%(x2)s  %(y2)s lineto
%(x)s  %(y2)s lineto
closepath
''' 



class GridBox(Box):
    def __init__(self, x=0, y=0, linewidth=1, linegray=0, gray=0,
            angle=0.0, w=50, h=None, grid=10, glinewidth=1,
            ggray=0.9, dash=None):
        StrokeFill.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                dash=dash)
        if not h: h = w * goldenratio
        self.w = w
        self.h = h
        self.gray = 0       # ! gray is for the self.box !
        self.box = Box(x=x, y=x, linewidth=linewidth,
                linegray=linegray, gray=gray, angle=angle, w=w, h=h)
        self.grid = grid
        self.glinewidth= glinewidth
        self.ggray = ggray
        self.x2 = x+w
        self.y2 = y+h
        self.minx, self.miny = x-linewidth, y-linewidth
        self.maxx, self.maxy = self.x2+linewidth, self.y2+linewidth
    def epsCode(self):
        resultStr = self.box.epsText()
        # Horizontal lines
        vpoints = []
        for y in range(int(math.floor(self.miny)),
                int(math.ceil(self.maxy))):
            if y % self.grid == 0: vpoints.append(y)
        for y in vpoints:
            resultStr = resultStr + Line(x=self.minx, y=y,
                    x2=self.maxx, y2=y, linegray=self.ggray,
                    linewidth=self.glinewidth).epsText()
        # Vertical lines
        hpoints = []
        for x in range(int(math.floor(self.minx)),
                int(math.ceil(self.maxx))):
            if x % self.grid == 0: hpoints.append(x)
        for x in hpoints:
            resultStr = resultStr + Line(x=x, y=self.miny, x2=x,
                    y2=self.maxy, linegray=self.ggray,
                    linewidth=self.glinewidth).epsText()
        return resultStr



###
# Same as StrokeFill but with a Postscript translate instruction as
# the first thing in the representation.
###
class TranslatedStrokeFill(StrokeFill):
    def epsText(self):
        resultStr = '''
gsave
%(x)s %(y)s translate
'''
        if self.angle:
            resultStr = resultStr + '''\
%(angle)s rotate
'''
        if self.xscale != 1 or self.yscale !=1:
            resultStr = resultStr + '''\
%(xscale)s %(yscale)s scale
'''
        smallCode = self.epsCode()
        if smallCode: resultStr = resultStr + smallCode 
        if self.gray:
            resultStr = resultStr + '''\
gsave
%(gray)s setgray fill
grestore
'''
        if self.linewidth:
            resultStr = resultStr + '%(linewidth)s' 
            resultStr = resultStr + ' setlinewidth\n'
            if self.linegray:
                resultStr = resultStr + '%(linegray)s' 
                resultStr = resultStr + ' setgray\n'
            if self.dash:
                dashParams = '['
                i = 0
                patternList = self.dash[:-1]
                for p in patternList:
                    dashParams = dashParams + repr(p) 
                    if i < len(patternList)-1:
                        dashParams = dashParams + ' ' 
                    i = i + 1
                dashParams = dashParams + '] ' 
                dashParams = dashParams + repr(self.dash[-1])
                resultStr = resultStr + dashParams + ' setdash\n'
            resultStr = resultStr + 'stroke\n'
            if self.dash:
                resultStr = resultStr + '[] 0 setdash\n'
        resultStr = resultStr + 'grestore\n'
        return resultStr % self.__dict__



##
# __init__ constructor parameters:
# (x, y)  centre of circle.
# r         radius.
# start     angle in degrees from where to start drawing
#           counterclockwise.
# end       angle where to stop drawing.
# (xscale, yscale)  X and Y scaling.  Useful for (part of) ellipses.
##
class Circle(TranslatedStrokeFill):
    def __init__(self, x=20, y=20, gray=0, linewidth=1, linegray=0,
            angle=0.0, r=40, start=0, end=360, xscale=1.0,
            yscale=1.0, dash=None):
        TranslatedStrokeFill.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                xscale=xscale, yscale=yscale, dash=dash)
        self.r = r
        self.start, self.end = start, end
        minX, maxX = x - r, x + r
        minY, maxY = y - r, y + r
        self.minx, self.miny = minX-linewidth, minY-linewidth
        self.maxx, self.maxy = maxX+linewidth, maxY+linewidth
    def epsCode(self):
        return '''\
newpath
0 0 %(r)s %(start)s %(end)s arc
''' 



##
# __init__ constructor parameters:
# (x, y)    where from
# (x2, y2)  where to
# ahw       width of the arrow head
# ahl       length of the arrow head
# gray      fill color for arrow head
##
class Arrow(TranslatedStrokeFill):
    def __init__(self, x=20, y=20, gray=0, linewidth=1, linegray=0,
            angle=0.0, x2=100, y2=100, sw=4, ahw=None, ahl=None,
            dash=None, xscale=1.0, yscale=1.0):
        TranslatedStrokeFill.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                xscale=xscale, yscale=yscale, dash=dash)
        if not ahw: ahw = sw / goldenratio
        if not ahl: ahl = sw / goldenratio
        dx = (x2 - x) * 1.0
        dy = (y2 - y) * 1.0
        self.angle = math.degrees(math.atan2(dy, dx))
        self.arrowlength = math.hypot(dx, dy) 
        self.base = self.arrowlength - ahl
        self.halfthickness = sw / 2.0
        self.halfheadthickness = ahw / 2.0
        self.xvalues = [x, x2, 
                x - self.halfthickness,
                x2 - self.halfthickness,
                x + self.halfthickness,
                x2 + self.halfthickness]
        self.yvalues = [y, y2,
                y - self.halfthickness,
                y2 - self.halfthickness,
                y + self.halfthickness,
                y2 + self.halfthickness]
        self.setminmax()
    def setminmax(self):
        self.minx = min(self.xvalues) - self.linewidth
        self.miny = min(self.yvalues) - self.linewidth
        self.maxx = max(self.xvalues) + self.linewidth
        self.maxy = max(self.yvalues) + self.linewidth
    def epsCode(self):
        return '''\
newpath
0 0 moveto
%(arrowlength)s 0 lineto
%(base)s %(halfheadthickness)s neg moveto
%(arrowlength)s 0 lineto
%(base)s %(halfheadthickness)s lineto
'''



##
# __init__ constructor parameters:
# (x, y)    where from
# (x2, y2)  where to
# sw        width of the stem
# ahw       width of the arrow head
# ahl       length of the arrow head
# gray      fill color for whole arrow
# linegray  color for arrow outline
# linewidth width of arrow outline
##
class OpenArrow(Arrow):
    def __init__(self, x=20, y=20, gray=0, linewidth=1, linegray=0,
            angle=0.0, x2=100, y2=100, sw=4, ahw=None, ahl=None,
            dash=None, xscale=1.0, yscale=1.0):
        Arrow.__init__(self, x=x, y=y, gray=gray,
                linewidth=linewidth, linegray=linegray, angle=angle,
                x2=x2, y2=y2, sw=sw, ahw=ahw, ahl=ahl,
                xscale=xscale, yscale=yscale, dash=dash)
    def epsCode(self):
        return '''\
newpath
0 %(halfthickness)s neg moveto
%(base)s %(halfthickness)s neg lineto
%(base)s %(halfheadthickness)s neg lineto
%(arrowlength)s 0 lineto
%(base)s %(halfheadthickness)s lineto
%(base)s %(halfthickness)s lineto
0 %(halfthickness)s lineto
closepath
'''



##
# A class that can determine the BoundingBox itself, but only if all
# its elements have attributes minx, miny, maxx and maxy.
##
class AutoPicture:
    def __init__(self):
        self._intro = r'''%!
%%Creator: pyps.py
'''
        self._introBoxPat = '''\
%%%%BoundingBox: %(minx)s %(miny)s %(maxx)s %(maxy)s

'''
        self.elements = []
        # silly inital min and max values
        self.minx, self.miny = 1000000, 1000000
        self.maxx, self.maxy = -1000000, -1000000
    def add(self, what): self.elements.append(what)
    def prepend(self, what): 
        self.elements = [what] + self.elements
    ##
    # @return Tuple (minx, miny, maxx, maxy)
    ##
    def dimensions(self):
        for e in self.elements:
            floor, ceil = math.floor, math.ceil
            if e.minx < self.minx: 
                self.minx = int(min(floor(e.minx), ceil(e.minx)))
            if e.miny < self.miny: 
                self.miny = int(min(floor(e.miny), ceil(e.miny)))
            if e.maxx > self.maxx: 
                self.maxx = int(max(floor(e.maxx), ceil(e.maxx)))
            if e.maxy > self.maxy: 
                self.maxy = int(max(floor(e.maxy), ceil(e.maxy)))
        return (self.minx, self.miny, self.maxx, self.maxy)
    def epsText(self):
        self.minx, self.miny, self.maxx, self.maxy = self.dimensions()
        cList = [self._intro + self._introBoxPat % self.__dict__]
        for e in self.elements: cList.append(e.epsText())
        return string.join(cList, '') 




##
# Return a string: the contents of a test .eps file.
def test():
    p = AutoPicture()
    # A smal box, filled, default width=50 height=50*goldenratio
    p.add(Box(x=20, y=40, gray=1))
    # Lines
    p.add(Line(x=30, y=20, x2=50, y2=60))
    p.add(Line(x=40, y=20, x2=60, y2=60, linegray=0.2, dash=(3,3,0)))
    p.add(Line(x=45, y=20, x2=65, y2=60, linegray=0.4))
    p.add(Line(x=50, y=20, x2=70, y2=60, linegray=0.6, dash=(9,5,3)))
    p.add(Line(x=55, y=20, x2=75, y2=60, linegray=0.8))
    # An open arrow
    p.add(OpenArrow(x=20, y=20, x2=40, y2=60, gray=0.4))
    # A normal arrow
    p.add(Arrow(x=10, y=20, x2=30, y2=60))
    # Text
    p.add(Text(x=4, y=5, font='Courier-New', text='Courier-New spam'))
    p.add(Text(x=100, y=5, font='Arial', text='Arial spam'))
    p.add(Text(x=65, y=20, angle=math.degrees(math.atan2(2,1)),
            font='Verdana', text='Verdana spam'))
    p.add(Text(x=24, y=80, angle=0, scalefont=32, charpath=1,
                gray=0.7, text="Spam, ham and eggs"))
    #
    # Circles
    p.add(Circle(x=14, y=30, r=6))
    p.add(Circle(x=100, y=30, r=6, start=90, end=180, dash=(2,2,0)))
    p.add(Circle(x=120, y=30, r=6, start=0, end=180, yscale=2.0,
            linegray=0.5, gray=0.4))
    p.add(Circle(x=160, y=30, r=6, start=0, end=180, xscale=1.5,
            linewidth=0, gray=0.6))
    # This one is rotated
    p.add(Circle(x=140, y=30, r=6, start=0, end=180, xscale=0.5,
            angle=30.0))
    #
    # Now calculate the dimensions of the picture
    d = p.dimensions()
    log('BoundingBox of the generated picture: '+ repr(d))
    # PREPEND a background with the max dimensions
    #p.prepend(Box(x=d[0], y=d[1], w=d[2]-d[0], h=d[3]-d[1],
    #        linewidth=0, gray=0.95))
    p.prepend(GridBox(x=d[0], y=d[1], w=d[2]-d[0], h=d[3]-d[1],
            linewidth=0, gray=0.95, grid=10, glinewidth=0.5,
            ggray=0.8))
    return p.epsText()



if __name__ == '__main__': 
    print test()




More information about the Python-list mailing list