more PDF work: beginnings of text support
[PyX/mjg.git] / pyx / canvas.py
blobb47d639d7cc9b9ae4ec48fbe15d13511cbdb343f
1 #!/usr/bin/env python
2 # -*- coding: ISO-8859-1 -*-
5 # Copyright (C) 2002-2004 Jörg Lehmann <joergl@users.sourceforge.net>
6 # Copyright (C) 2002-2004 André Wobst <wobsta@users.sourceforge.net>
8 # This file is part of PyX (http://pyx.sourceforge.net/).
10 # PyX is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 2 of the License, or
13 # (at your option) any later version.
15 # PyX is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 # GNU General Public License for more details.
20 # You should have received a copy of the GNU General Public License
21 # along with PyX; if not, write to the Free Software
22 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
24 # XXX what are the correct base classes of clip and pattern
26 """The canvas module provides a PostScript canvas class and related classes
28 A canvas holds a collection of all elements that should be displayed together
29 with their attributes.
30 """
32 import sys, cStringIO, math, time
33 import attr, base, bbox, deco, unit, prolog, style, trafo, version
35 try:
36 enumerate([])
37 except NameError:
38 # fallback implementation for Python 2.2. and below
39 def enumerate(list):
40 return zip(xrange(len(list)), list)
42 # known paperformats as tuple (width, height)
44 _paperformats = { "A4" : (210 * unit.t_mm, 297 * unit.t_mm),
45 "A3" : (297 * unit.t_mm, 420 * unit.t_mm),
46 "A2" : (420 * unit.t_mm, 594 * unit.t_mm),
47 "A1" : (594 * unit.t_mm, 840 * unit.t_mm),
48 "A0" : (840 * unit.t_mm, 1188 * unit.t_mm),
49 "A0b" : (910 * unit.t_mm, 1370 * unit.t_mm),
50 "Letter" : (8.5 * unit.t_inch, 11 * unit.t_inch),
51 "Legal" : (8.5 * unit.t_inch, 14 * unit.t_inch)}
54 # clipping class
57 class clip(base.PSCmd):
59 """class for use in canvas constructor which clips to a path"""
61 def __init__(self, path):
62 """construct a clip instance for a given path"""
63 self.path = path
65 def bbox(self):
66 # as a PSCmd a clipping path has NO influence on the bbox...
67 return None
69 def clipbbox(self):
70 # ... but for clipping, we nevertheless need the bbox
71 return self.path.bbox()
73 def outputPS(self, file):
74 file.write("newpath\n")
75 self.path.outputPS(file)
76 file.write("clip\n")
78 def outputPDF(self, file):
79 self.path.outputPDF(file)
80 file.write("W n\n")
83 # general canvas class
86 class _canvas(base.PSCmd):
88 """a canvas is a collection of PSCmds together with PSAttrs"""
90 def __init__(self, attrs=[], texrunner=None):
92 """construct a canvas
94 The canvas can be modfied by supplying args, which have
95 to be instances of one of the following classes:
96 - trafo.trafo (leading to a global transformation of the canvas)
97 - canvas.clip (clips the canvas)
98 - base.PathStyle (sets some global attributes of the canvas)
100 Note that, while the first two properties are fixed for the
101 whole canvas, the last one can be changed via canvas.set().
103 The texrunner instance used for the text method can be specified
104 using the texrunner argument. It defaults to text.defaulttexrunner
108 self.PSOps = []
109 self.trafo = trafo.trafo()
110 self.clipbbox = None
111 if texrunner is not None:
112 self.texrunner = texrunner
113 else:
114 # prevent cyclic imports
115 import text
116 self.texrunner = text.defaulttexrunner
118 for attr in attrs:
119 if isinstance(attr, trafo.trafo_pt):
120 self.trafo = self.trafo*attr
121 self.PSOps.append(attr)
122 elif isinstance(attr, clip):
123 if self.clipbbox is None:
124 self.clipbbox = attr.clipbbox().transformed(self.trafo)
125 else:
126 self.clippbox *= attr.clipbbox().transformed(self.trafo)
127 self.PSOps.append(attr)
128 else:
129 self.set([attr])
131 def bbox(self):
132 """returns bounding box of canvas"""
133 obbox = None
134 for cmd in self.PSOps:
135 if isinstance(cmd, base.PSCmd):
136 abbox = cmd.bbox()
137 if obbox is None:
138 obbox = abbox
139 elif abbox is not None:
140 obbox += abbox
142 # transform according to our global transformation and
143 # intersect with clipping bounding box (which have already been
144 # transformed in canvas.__init__())
145 if obbox is not None and self.clipbbox is not None:
146 return obbox.transformed(self.trafo)*self.clipbbox
147 elif obbox is not None:
148 return obbox.transformed(self.trafo)
149 else:
150 return self.clipbbox
152 def prolog(self):
153 result = []
154 for cmd in self.PSOps:
155 result.extend(cmd.prolog())
156 return result
158 def outputPS(self, file):
159 if self.PSOps:
160 file.write("gsave\n")
161 for cmd in self.PSOps:
162 cmd.outputPS(file)
163 file.write("grestore\n")
165 def outputPDF(self, file):
166 if self.PSOps:
167 file.write("q\n") # gsave
168 for cmd in self.PSOps:
169 cmd.outputPDF(file)
170 file.write("Q\n") # grestore
172 def insert(self, PSOp, attrs=[]):
173 """insert PSOp in the canvas.
175 If attrss are given, a canvas containing the PSOp is inserted applying attrs.
177 returns the PSOp
181 # XXX check for PSOp
183 if attrs:
184 sc = _canvas(attrs)
185 sc.insert(PSOp)
186 self.PSOps.append(sc)
187 else:
188 self.PSOps.append(PSOp)
190 return PSOp
192 def set(self, attrs):
193 """sets styles args globally for the rest of the canvas
196 attr.checkattrs(attrs, [style.strokestyle, style.fillstyle])
197 for astyle in attrs:
198 self.insert(astyle)
200 def draw(self, path, attrs):
201 """draw path on canvas using the style given by args
203 The argument attrs consists of PathStyles, which modify
204 the appearance of the path, PathDecos, which add some new
205 visual elements to the path, or trafos, which are applied
206 before drawing the path.
210 attrs = attr.mergeattrs(attrs)
211 attr.checkattrs(attrs, [deco.deco, style.fillstyle, style.strokestyle, trafo.trafo_pt])
213 for t in attr.getattrs(attrs, [trafo.trafo_pt]):
214 path = path.transformed(t)
216 dp = deco.decoratedpath(path)
218 # set global styles
219 dp.styles = attr.getattrs(attrs, [style.fillstyle, style.strokestyle])
221 # add path decorations and modify path accordingly
222 for adeco in attr.getattrs(attrs, [deco.deco]):
223 dp = adeco.decorate(dp)
225 self.insert(dp)
227 def stroke(self, path, attrs=[]):
228 """stroke path on canvas using the style given by args
230 The argument attrs consists of PathStyles, which modify
231 the appearance of the path, PathDecos, which add some new
232 visual elements to the path, or trafos, which are applied
233 before drawing the path.
237 self.draw(path, [deco.stroked]+list(attrs))
239 def fill(self, path, attrs=[]):
240 """fill path on canvas using the style given by args
242 The argument attrs consists of PathStyles, which modify
243 the appearance of the path, PathDecos, which add some new
244 visual elements to the path, or trafos, which are applied
245 before drawing the path.
249 self.draw(path, [deco.filled]+list(attrs))
251 def settexrunner(self, texrunner):
252 """sets the texrunner to be used to within the text and text_pt methods"""
254 self.texrunner = texrunner
256 def text(self, x, y, atext, *args, **kwargs):
257 """insert a text into the canvas
259 inserts a textbox created by self.texrunner.text into the canvas
261 returns the inserted textbox"""
263 return self.insert(self.texrunner.text(x, y, atext, *args, **kwargs))
266 def text_pt(self, x, y, atext, *args):
267 """insert a text into the canvas
269 inserts a textbox created by self.texrunner.text_pt into the canvas
271 returns the inserted textbox"""
273 return self.insert(self.texrunner.text_pt(x, y, atext, *args))
276 # canvas for patterns
279 class pattern(_canvas, attr.exclusiveattr, style.fillstyle):
281 def __init__(self, painttype=1, tilingtype=1, xstep=None, ystep=None, bbox=None, trafo=None):
282 attr.exclusiveattr.__init__(self, pattern)
283 _canvas.__init__(self)
284 attr.exclusiveattr.__init__(self, pattern)
285 self.id = "pattern%d" % id(self)
286 if painttype not in (1,2):
287 raise ValueError("painttype must be 1 or 2")
288 self.painttype = painttype
289 if tilingtype not in (1,2,3):
290 raise ValueError("tilingtype must be 1, 2 or 3")
291 self.tilingtype = tilingtype
292 self.xstep = xstep
293 self.ystep = ystep
294 self.patternbbox = bbox
295 self.patterntrafo = trafo
297 def bbox(self):
298 return None
300 def outputPS(self, file):
301 file.write("%s setpattern\n" % self.id)
303 def prolog(self):
304 realpatternbbox = _canvas.bbox(self)
305 if self.xstep is None:
306 xstep = unit.topt(realpatternbbox.width())
307 else:
308 xstep = unit.topt(unit.length(self.xstep))
309 if self.ystep is None:
310 ystep = unit.topt(realpatternbbox.height())
311 else:
312 ystep = unit.topt(unit.length(self.ystep))
313 if not xstep:
314 raise ValueError("xstep in pattern cannot be zero")
315 if not ystep:
316 raise ValueError("ystep in pattern cannot be zero")
317 patternbbox = self.patternbbox or realpatternbbox.enlarged("5 pt")
319 patternprefix = "\n".join(("<<",
320 "/PatternType 1",
321 "/PaintType %d" % self.painttype,
322 "/TilingType %d" % self.tilingtype,
323 "/BBox[%s]" % str(patternbbox),
324 "/XStep %g" % xstep,
325 "/YStep %g" % ystep,
326 "/PaintProc {\nbegin\n"))
327 stringfile = cStringIO.StringIO()
328 _canvas.outputPS(self, stringfile)
329 patternproc = stringfile.getvalue()
330 stringfile.close()
331 patterntrafostring = self.patterntrafo is None and "matrix" or str(self.patterntrafo)
332 patternsuffix = "end\n} bind\n>>\n%s\nmakepattern" % patterntrafostring
334 pr = _canvas.prolog(self)
335 pr.append(prolog.definition(self.id, "".join((patternprefix, patternproc, patternsuffix))))
336 return pr
338 pattern.clear = attr.clearclass(pattern)
340 # helper function
342 def calctrafo(abbox, paperformat, margin, rotated, fittosize):
343 """ calculate a trafo which rotates and fits a canvas with
344 bounding box abbox on the given paperformat with a margin on all
345 sides"""
346 margin = unit.length(margin)
347 atrafo = None # global transformation of canvas
349 if rotated:
350 atrafo = trafo.rotate(90, *abbox.center())
352 if paperformat:
353 # center (optionally rotated) output on page
354 try:
355 paperwidth, paperheight = _paperformats[paperformat.capitalize()]
356 except KeyError:
357 raise KeyError, "unknown paperformat '%s'" % paperformat
359 paperwidth -= 2*margin
360 paperheight -= 2*margin
362 if not atrafo: atrafo = trafo.trafo()
364 atrafo = atrafo.translated(margin + 0.5*(paperwidth - abbox.width()) - abbox.left(),
365 margin + 0.5*(paperheight - abbox.height()) - abbox.bottom())
367 if fittosize:
368 # scale output to pagesize - margins
369 if 2*margin > min(paperwidth, paperheight):
370 raise RuntimeError("Margins too broad for selected paperformat. Aborting.")
372 if rotated:
373 sfactor = min(unit.topt(paperheight)/unit.topt(abbox.width()),
374 unit.topt(paperwidth)/unit.topt(abbox.height()))
375 else:
376 sfactor = min(unit.topt(paperwidth)/unit.topt(abbox.width()),
377 unit.topt(paperheight)/unit.topt(abbox.height()))
379 atrafo = atrafo.scaled(sfactor, sfactor, margin + 0.5*paperwidth, margin + 0.5*paperheight)
380 elif fittosize:
381 raise ValueError("must specify paper size for fittosize")
383 return atrafo
386 # The main canvas class
389 class canvas(_canvas):
391 """a canvas is a collection of PSCmds together with PSAttrs"""
393 def writeEPSfile(self, filename, paperformat=None, rotated=0, fittosize=0, margin="1 t cm",
394 bbox=None, bboxenlarge="1 t pt"):
395 """write canvas to EPS file
397 If paperformat is set to a known paperformat, the output will be centered on
398 the page.
400 If rotated is set, the output will first be rotated by 90 degrees.
402 If fittosize is set, then the output is scaled to the size of the
403 page (minus margin). In that case, the paperformat the specification
404 of the paperformat is obligatory.
406 The bbox parameter overrides the automatic bounding box determination.
407 bboxenlarge may be used to enlarge the bbox of the canvas (or the
408 manually specified bbox).
411 if filename[-4:]!=".eps":
412 filename = filename + ".eps"
414 try:
415 file = open(filename, "w")
416 except IOError:
417 raise IOError("cannot open output file")
419 abbox = bbox is not None and bbox or self.bbox()
420 abbox.enlarge(bboxenlarge)
421 ctrafo = calctrafo(abbox, paperformat, margin, rotated, fittosize)
423 # if there has been a global transformation, adjust the bounding box
424 # accordingly
425 if ctrafo: abbox.transform(ctrafo)
427 file.write("%!PS-Adobe-3.0 EPSF 3.0\n")
428 abbox.outputPS(file)
429 file.write("%%%%Creator: PyX %s\n" % version.version)
430 file.write("%%%%Title: %s\n" % filename)
431 file.write("%%%%CreationDate: %s\n" %
432 time.asctime(time.localtime(time.time())))
433 file.write("%%EndComments\n")
435 file.write("%%BeginProlog\n")
437 mergedprolog = []
439 for pritem in self.prolog():
440 for mpritem in mergedprolog:
441 if mpritem.merge(pritem) is None: break
442 else:
443 mergedprolog.append(pritem)
445 for pritem in mergedprolog:
446 pritem.outputPS(file)
448 file.write("%%EndProlog\n")
450 # apply a possible global transformation
451 if ctrafo: ctrafo.outputPS(file)
453 file.write("%f setlinewidth\n" % unit.topt(style.linewidth.normal))
455 # here comes the actual content
456 self.outputPS(file)
458 file.write("showpage\n")
459 file.write("%%Trailer\n")
460 file.write("%%EOF\n")
462 def writePDFfile(self, filename, paperformat=None, rotated=0, fittosize=0, margin="1 t cm",
463 bbox=None, bboxenlarge="1 t pt"):
464 sys.stderr.write("*** PyX Warning: writePDFfile is experimental and supports only a subset of PyX's features\n")
466 if filename[-4:]!=".pdf":
467 filename = filename + ".pdf"
469 try:
470 file = open(filename, "wb")
471 except IOError:
472 raise IOError("cannot open output file")
474 abbox = bbox is not None and bbox or self.bbox()
475 abbox.enlarge(bboxenlarge)
477 ctrafo = calctrafo(abbox, paperformat, margin, rotated, fittosize)
479 # if there has been a global transformation, adjust the bounding box
480 # accordingly
481 if ctrafo: abbox.transform(ctrafo)
483 mergedprolog = []
485 for pritem in self.prolog():
486 for mpritem in mergedprolog:
487 if mpritem.merge(pritem) is None: break
488 else:
489 mergedprolog.append(pritem)
491 file.write("%%PDF-1.4\n%%%s%s%s%s\n" % (chr(195), chr(182), chr(195), chr(169)))
492 reflist = [file.tell()]
493 file.write("1 0 obj\n"
494 "<<\n"
495 "/Type /Catalog\n"
496 "/Outlines 2 0 R\n"
497 "/Pages 3 0 R\n"
498 ">>\n"
499 "endobj\n")
500 reflist.append(file.tell())
501 file.write("2 0 obj\n"
502 "<<\n"
503 "/Type Outlines\n"
504 "/Count 0\n"
505 ">>\n"
506 "endobj\n")
507 reflist.append(file.tell())
508 file.write("3 0 obj\n"
509 "<<\n"
510 "/Type /Pages\n"
511 "/Kids [4 0 R]\n"
512 "/Count 1\n"
513 ">>\n"
514 "endobj\n")
515 reflist.append(file.tell())
516 file.write("4 0 obj\n"
517 "<<\n"
518 "/Type /Page\n"
519 "/Parent 3 0 R\n")
520 abbox.outputPDF(file)
521 file.write("/Contents 5 0 R\n"
522 "/Resources <<\n"
523 "/ProcSet 7 0 R\n")
525 fontnr = 0
526 for pritem in mergedprolog:
527 if isinstance(pritem, prolog.fontreencoding):
528 fontnr += 1
529 file.write("/Font << /%s %d 0 R>>\n" % (pritem.fontname, fontnr+7))
531 file.write(">>\n"
532 ">>\n"
533 "endobj\n")
534 reflist.append(file.tell())
535 file.write("5 0 obj\n"
536 "<< /Length 6 0 R >>\n"
537 "stream\n")
538 streamstartpos = file.tell()
540 # apply a possible global transformation
541 if ctrafo: ctrafo.outputPDF(file)
542 style.linewidth.normal.outputPDF(file)
544 self.outputPDF(file)
545 streamendpos = file.tell()
546 file.write("endstream\n"
547 "endobj\n")
548 reflist.append(file.tell())
549 file.write("6 0 obj\n"
550 "%s\n"
551 "endobj\n" % (streamendpos - streamstartpos))
552 reflist.append(file.tell())
553 file.write("7 0 obj\n"
554 "[/PDF /Text]\n"
555 "endobj\n")
557 fontnr = 0
558 for pritem in mergedprolog:
559 if isinstance(pritem, prolog.fontreencoding):
560 fontnr += 1
561 reflist.append(file.tell())
562 file.write("%d 0 obj\n"
563 "<<\n"
564 "/Type /Font\n"
565 "/Subtype /Type1\n"
566 "/Name /%s\n"
567 "/BaseFont /Helvetica\n"
568 "/Encoding /MacRomanEncoding\n"
569 ">>\n"
570 "endobj\n" % (fontnr+7, pritem.fontname))
572 xrefpos = file.tell()
573 file.write("xref\n"
574 "0 %d\n" % (len(reflist)+1))
575 file.write("0000000000 65535 f \n")
576 for ref in reflist:
577 file.write("%010i 00000 n \n" % ref)
578 file.write("trailer\n"
579 "<<\n"
580 "/Size 8\n"
581 "/Root 1 0 R\n"
582 ">>\n"
583 "startxref\n"
584 "%i\n"
585 "%%%%EOF\n" % xrefpos)
587 def writetofile(self, filename, *args, **kwargs):
588 if filename[-4:] == ".eps":
589 self.writeEPSfile(filename, *args, **kwargs)
590 elif filename[-4:] == ".pdf":
591 self.writePDFfile(filename, *args, **kwargs)
592 else:
593 sys.stderr.write("*** PyX Warning: deprecated usage of writetofile -- writetofile needs a filename extension or use an explicit call to writeEPSfile or the like\n")
594 self.writeEPSfile(filename, *args, **kwargs)
596 class page(canvas):
598 def __init__(self, attrs=[], texrunner=None, pagename=None, paperformat="a4", rotated=0, fittosize=0,
599 margin="1 t cm", bboxenlarge="1 t pt"):
600 canvas.__init__(self, attrs, texrunner)
601 self.pagename = pagename
602 self.paperformat = paperformat.capitalize()
603 self.rotated = rotated
604 self.fittosize = fittosize
605 self.margin = margin
606 self.bboxenlarge = bboxenlarge
608 def bbox(self):
609 # the bounding box of a page is fixed by its format and an optional rotation
610 pbbox = bbox.bbox(0, 0, *_paperformats[self.paperformat])
611 pbbox.enlarge(self.bboxenlarge)
612 if self.rotated:
613 pbbox.transform(trafo.rotate(90, *pbbox.center()))
614 return pbbox
616 def outputPS(self, file):
617 file.write("%%%%PageMedia: %s\n" % self.paperformat)
618 file.write("%%%%PageOrientation: %s\n" % (self.rotated and "Landscape" or "Portrait"))
619 # file.write("%%%%PageBoundingBox: %d %d %d %d\n" % (math.floor(pbbox.llx_pt), math.floor(pbbox.lly_pt),
620 # math.ceil(pbbox.urx_pt), math.ceil(pbbox.ury_pt)))
622 # page setup section
623 file.write("%%BeginPageSetup\n")
624 file.write("/pgsave save def\n")
625 # for scaling, we need the real bounding box of the page contents
626 pbbox = canvas.bbox(self)
627 pbbox.enlarge(self.bboxenlarge)
628 ptrafo = calctrafo(pbbox, self.paperformat, self.margin, self.rotated, self.fittosize)
629 if ptrafo:
630 ptrafo.outputPS(file)
631 file.write("%f setlinewidth\n" % unit.topt(style.linewidth.normal))
632 file.write("%%EndPageSetup\n")
634 # here comes the actual content
635 canvas.outputPS(self, file)
636 file.write("pgsave restore\n")
637 file.write("showpage\n")
638 # file.write("%%PageTrailer\n")
641 class document:
643 """holds a collection of page instances which are output as pages of a document"""
645 def __init__(self, pages=[]):
646 self.pages = pages
648 def append(self, page):
649 self.pages.append(page)
651 def writePSfile(self, filename):
652 """write pages to PS file """
654 if filename[-3:]!=".ps":
655 filename = filename + ".ps"
657 try:
658 file = open(filename, "w")
659 except IOError:
660 raise IOError("cannot open output file")
662 docbbox = None
663 for apage in self.pages:
664 pbbox = apage.bbox()
665 if docbbox is None:
666 docbbox = pbbox
667 else:
668 docbbox += pbbox
670 # document header
671 file.write("%!PS-Adobe-3.0\n")
672 docbbox.outputPS(file)
673 file.write("%%%%Creator: PyX %s\n" % version.version)
674 file.write("%%%%Title: %s\n" % filename)
675 file.write("%%%%CreationDate: %s\n" %
676 time.asctime(time.localtime(time.time())))
677 # required paper formats
678 paperformats = {}
679 for apage in self.pages:
680 if isinstance(apage, page):
681 paperformats[apage.paperformat] = _paperformats[apage.paperformat]
682 first = 1
683 for paperformat, size in paperformats.items():
684 if first:
685 file.write("%%DocumentMedia: ")
686 first = 0
687 else:
688 file.write("%%+ ")
689 file.write("%s %d %d 75 white ()\n" % (paperformat, unit.topt(size[0]), unit.topt(size[1])))
691 file.write("%%%%Pages: %d\n" % len(self.pages))
692 file.write("%%PageOrder: Ascend\n")
693 file.write("%%EndComments\n")
695 # document default section
696 #file.write("%%BeginDefaults\n")
697 #if paperformat:
698 # file.write("%%%%PageMedia: %s\n" % paperformat)
699 #file.write("%%%%PageOrientation: %s\n" % (rotated and "Landscape" or "Portrait"))
700 #file.write("%%EndDefaults\n")
702 # document prolog section
703 file.write("%%BeginProlog\n")
704 mergedprolog = []
705 for apage in self.pages:
706 for pritem in apage.prolog():
707 for mpritem in mergedprolog:
708 if mpritem.merge(pritem) is None: break
709 else:
710 mergedprolog.append(pritem)
711 for pritem in mergedprolog:
712 pritem.outputPS(file)
713 file.write("%%EndProlog\n")
715 # document setup section
716 #file.write("%%BeginSetup\n")
717 #file.write("%%EndSetup\n")
719 # pages section
720 for nr, apage in enumerate(self.pages):
721 file.write("%%%%Page: %s %d\n" % (apage.pagename is None and str(nr) or apage.pagename , nr+1))
722 apage.outputPS(file)
724 file.write("%%Trailer\n")
725 file.write("%%EOF\n")