1 # -*- encoding: utf-8 -*-
4 # Copyright (C) 2002-2012 Jörg Lehmann <joergl@users.sourceforge.net>
5 # Copyright (C) 2002-2012 André Wobst <wobsta@users.sourceforge.net>
7 # This file is part of PyX (http://pyx.sourceforge.net/).
9 # PyX is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; either version 2 of the License, or
12 # (at your option) any later version.
14 # PyX is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
19 # You should have received a copy of the GNU General Public License
20 # along with PyX; if not, write to the Free Software
21 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
23 """The canvas module provides a PostScript canvas class and related classes
25 A canvas holds a collection of all elements and corresponding attributes to be
28 import io
, logging
, os
, sys
, string
, tempfile
29 from . import attr
, baseclasses
, config
, document
, style
, trafo
30 from . import bbox
as bboxmodule
32 logger
= logging
.getLogger("pyx")
34 def _wrappedindocument(method
):
35 def wrappedindocument(self
, file=None, **kwargs
):
38 for name
, value
in list(kwargs
.items()):
39 if name
.startswith("page_"):
40 page_kwargs
[name
[5:]] = value
41 elif name
.startswith("write_"):
42 write_kwargs
[name
[6:]] = value
44 logger
.warning("implicit page keyword argument passing is deprecated; keyword argument '%s' of %s method should be changed to 'page_%s'" %
45 (name
, method
.__name
__, name
))
46 page_kwargs
[name
] = value
47 d
= document
.document([document
.page(self
, **page_kwargs
)])
48 self
.__name
__ = method
.__name
__
49 self
.__doc
__ = method
.__doc
__
50 return method(d
, file, **write_kwargs
)
51 return wrappedindocument
57 class clip(attr
.attr
):
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"""
65 def processPS(self
, file, writer
, context
, registry
):
66 file.write("newpath\n")
67 self
.path
.outputPS(file, writer
)
70 def processPDF(self
, file, writer
, context
, registry
):
71 self
.path
.outputPDF(file, writer
)
76 # general canvas class
79 class canvas(baseclasses
.canvasitem
):
81 """a canvas holds a collection of canvasitems"""
83 def __init__(self
, attrs
=None, texrunner
=None):
87 The canvas can be modfied by supplying a list of attrs, which have
88 to be instances of one of the following classes:
89 - trafo.trafo (leading to a global transformation of the canvas)
90 - canvas.clip (clips the canvas)
91 - style.strokestyle, style.fillstyle (sets some global attributes of the canvas)
93 Note that, while the first two properties are fixed for the
94 whole canvas, the last one can be changed via canvas.set().
96 The texrunner instance used for the text method can be specified
97 using the texrunner argument. It defaults to text.defaulttexrunner
102 self
.trafo
= trafo
.identity
107 if texrunner
is not None:
108 self
.texrunner
= texrunner
110 # prevent cyclic imports
112 self
.texrunner
= text
.defaulttexrunner
114 attr
.checkattrs(attrs
, [trafo
.trafo_pt
, clip
, style
.style
])
115 attrs
= attr
.mergeattrs(attrs
)
116 self
.modifies_state
= bool(attrs
)
118 self
.styles
= attr
.getattrs(attrs
, [style
.style
])
120 # trafos (and one possible clip operation) are applied from left to
121 # right in the attrs list -> reverse for calculating total trafo
122 for aattr
in reversed(attr
.getattrs(attrs
, [trafo
.trafo_pt
, clip
])):
123 if isinstance(aattr
, trafo
.trafo_pt
):
124 self
.trafo
= self
.trafo
* aattr
125 if self
.clip
is not None:
126 self
.clip
= clip(self
.clip
.path
.transformed(aattr
))
128 if self
.clip
is not None:
129 raise ValueError("single clipping allowed only")
133 return len(self
.items
)
135 def __getitem__(self
, i
):
138 def _repr_png_(self
):
140 Automatically represent as PNG graphic when evaluated in IPython notebook.
142 return self
.pipeGS(device
="png16m").getvalue()
145 """returns bounding box of canvas
147 Note that this bounding box doesn't take into account the linewidths, so
148 is less accurate than the one used when writing the output to a file.
150 obbox
= bboxmodule
.empty()
151 for cmd
in self
.items
:
154 # transform according to our global transformation and
155 # intersect with clipping bounding box (which has already been
156 # transformed in canvas.__init__())
157 obbox
.transform(self
.trafo
)
158 if self
.clip
is not None:
159 obbox
*= self
.clip
.path
.bbox()
162 def processPS(self
, file, writer
, context
, registry
, bbox
):
165 if self
.modifies_state
:
166 file.write("gsave\n")
167 for attr
in self
.styles
:
168 attr
.processPS(file, writer
, context
, registry
)
169 if self
.trafo
is not trafo
.identity
:
170 self
.trafo
.processPS(file, writer
, context
, registry
)
171 if self
.clip
is not None:
172 self
.clip
.processPS(file, writer
, context
, registry
)
173 nbbox
= bboxmodule
.empty()
174 for item
in self
.items
:
175 item
.processPS(file, writer
, context
, registry
, nbbox
)
176 # update bounding bbox
177 nbbox
.transform(self
.trafo
)
178 if self
.clip
is not None:
179 nbbox
*= self
.clip
.path
.bbox()
181 if self
.modifies_state
:
182 file.write("grestore\n")
184 def processPDF(self
, file, writer
, context
, registry
, bbox
):
187 context
.trafo
= context
.trafo
* self
.trafo
189 if self
.modifies_state
:
190 file.write("q\n") # gsave
191 for attr
in self
.styles
:
192 if isinstance(attr
, style
.fillstyle
):
193 context
.fillstyles
.append(attr
)
194 attr
.processPDF(file, writer
, context
, registry
)
195 if self
.trafo
is not trafo
.identity
:
196 self
.trafo
.processPDF(file, writer
, context
, registry
)
197 if self
.clip
is not None:
198 self
.clip
.processPDF(file, writer
, context
, registry
)
199 nbbox
= bboxmodule
.empty()
200 for item
in self
.items
:
201 if not writer
.text_as_path
:
202 if item
.requiretextregion():
210 context
.selectedfont
= None
211 item
.processPDF(file, writer
, context
, registry
, nbbox
)
215 context
.selectedfont
= None
216 # update bounding bbox
217 nbbox
.transform(self
.trafo
)
218 if self
.clip
is not None:
219 nbbox
*= self
.clip
.path
.bbox()
221 if self
.modifies_state
:
222 file.write("Q\n") # grestore
224 def layer(self
, name
, above
=None, below
=None):
225 """create or get a layer with name
227 A layer is a canvas itself and can be used to combine drawing
228 operations for ordering purposes, i.e., what is above and below each
229 other. The layer name is a dotted string, where dots are used to form
230 a hierarchy of layer groups. When inserting a layer, it is put on top
231 of its layer group except when another layer of this group is specified
232 by means of the parameters above or below.
235 if above
is not None and below
is not None:
236 raise ValueError("above and below cannot be specified at the same time")
238 group
, layer
= name
.split(".", 1)
240 if name
in self
.layers
:
241 if above
is not None or below
is not None:
242 # remove for repositioning
243 self
.items
.remove(self
.layers
[name
])
246 self
.layers
[name
] = canvas(texrunner
=self
.texrunner
)
247 if above
is None and below
is None:
248 self
.items
.append(self
.layers
[name
])
251 if above
is not None:
252 self
.items
.insert(self
.items
.index(self
.layers
[above
])+1, self
.layers
[name
])
253 elif below
is not None:
254 self
.items
.insert(self
.items
.index(self
.layers
[below
]), self
.layers
[name
])
256 return self
.layers
[name
]
258 if not group
in self
.layers
:
259 self
.layers
[group
] = self
.insert(canvas(texrunner
=self
.texrunner
))
260 if above
is not None:
261 abovegroup
, above
= above
.split(".", 1)
262 assert abovegroup
== group
263 if below
is not None:
264 belowgroup
, below
= below
.split(".", 1)
265 assert belowgroup
== group
266 return self
.layers
[group
].layer(layer
, above
=above
, below
=below
)
268 def insert(self
, item
, attrs
=None):
269 """insert item in the canvas.
271 If attrs are passed, a canvas containing the item is inserted applying
272 attrs. If replace is not None, the new item is
273 positioned accordingly in the canvas.
275 returns the item, possibly wrapped in a canvas
279 if not isinstance(item
, baseclasses
.canvasitem
):
280 raise ValueError("only instances of baseclasses.canvasitem can be inserted into a canvas")
287 self
.items
.append(item
)
290 def draw(self
, path
, attrs
):
291 """draw path on canvas using the style given by args
293 The argument attrs consists of PathStyles, which modify
294 the appearance of the path, PathDecos, which add some new
295 visual elements to the path, or trafos, which are applied
296 before drawing the path.
300 attrs
= attr
.mergeattrs(attrs
)
301 attr
.checkattrs(attrs
, [deco
.deco
, baseclasses
.deformer
, style
.style
])
303 for adeformer
in attr
.getattrs(attrs
, [baseclasses
.deformer
]):
304 path
= adeformer
.deform(path
)
306 styles
= attr
.getattrs(attrs
, [style
.style
])
307 dp
= deco
.decoratedpath(path
, styles
=styles
)
309 # add path decorations and modify path accordingly
310 for adeco
in attr
.getattrs(attrs
, [deco
.deco
]):
311 adeco
.decorate(dp
, self
.texrunner
)
315 def stroke(self
, path
, attrs
=[]):
316 """stroke path on canvas using the style given by args
318 The argument attrs consists of PathStyles, which modify
319 the appearance of the path, PathDecos, which add some new
320 visual elements to the path, or trafos, which are applied
321 before drawing the path.
325 self
.draw(path
, [deco
.stroked
]+list(attrs
))
327 def fill(self
, path
, attrs
=[]):
328 """fill path on canvas using the style given by args
330 The argument attrs consists of PathStyles, which modify
331 the appearance of the path, PathDecos, which add some new
332 visual elements to the path, or trafos, which are applied
333 before drawing the path.
337 self
.draw(path
, [deco
.filled
]+list(attrs
))
339 def settexrunner(self
, texrunner
):
340 """sets the texrunner to be used to within the text and text_pt methods"""
342 self
.texrunner
= texrunner
344 def text(self
, x
, y
, atext
, *args
, **kwargs
):
345 """insert a text into the canvas
347 inserts a textbox created by self.texrunner.text into the canvas
349 returns the inserted textbox"""
351 return self
.insert(self
.texrunner
.text(x
, y
, atext
, *args
, **kwargs
))
354 def text_pt(self
, x
, y
, atext
, *args
):
355 """insert a text into the canvas
357 inserts a textbox created by self.texrunner.text_pt into the canvas
359 returns the inserted textbox"""
361 return self
.insert(self
.texrunner
.text_pt(x
, y
, atext
, *args
))
363 writeEPSfile
= _wrappedindocument(document
.document
.writeEPSfile
)
364 writePSfile
= _wrappedindocument(document
.document
.writePSfile
)
365 writePDFfile
= _wrappedindocument(document
.document
.writePDFfile
)
366 writetofile
= _wrappedindocument(document
.document
.writetofile
)
369 def _gscmd(self
, device
, filename
, resolution
=100, gs
="gs", gsoptions
=[],
370 textalphabits
=4, graphicsalphabits
=4, ciecolor
=False, **kwargs
):
372 cmd
= [gs
, "-dEPSCrop", "-dNOPAUSE", "-dQUIET", "-dBATCH", "-r%d" % resolution
, "-sDEVICE=%s" % device
, "-sOutputFile=%s" % filename
]
373 if textalphabits
is not None:
374 cmd
.append("-dTextAlphaBits=%i" % textalphabits
)
375 if graphicsalphabits
is not None:
376 cmd
.append("-dGraphicsAlphaBits=%i" % graphicsalphabits
)
378 cmd
.append("-dUseCIEColor")
379 cmd
.extend(gsoptions
)
383 def writeGSfile(self
, filename
=None, device
=None, input="eps", **kwargs
):
385 convert EPS or PDF output to a file via Ghostscript
387 If filename is None it is auto-guessed from the script name. If
388 filename is "-", the output is written to stdout. In both cases, a
389 device needs to be specified to define the format.
391 If device is None, but a filename with suffix is given, PNG files will
392 be written using the png16m device and JPG files using the jpeg device.
395 if not sys
.argv
[0].endswith(".py"):
396 raise RuntimeError("could not auto-guess filename")
397 if device
.startswith("png"):
398 filename
= sys
.argv
[0][:-2] + "png"
399 elif device
.startswith("jpeg"):
400 filename
= sys
.argv
[0][:-2] + "jpg"
402 filename
= sys
.argv
[0][:-2] + device
404 if filename
.endswith(".png"):
406 elif filename
.endswith(".jpg"):
409 raise RuntimeError("could not auto-guess device")
411 cmd
, kwargs
= self
._gscmd
(device
, filename
, **kwargs
)
415 p
= config
.Popen(cmd
, stdin
=config
.PIPE
)
416 self
.writeEPSfile(p
.stdin
, **kwargs
)
420 # PDF files need to be accesible by random access and thus we need to create
422 with tempfile
.NamedTemporaryFile("wb", delete
=False) as f
:
423 self
.writePDFfile(f
, **kwargs
)
426 config
.Popen(cmd
).wait()
429 raise RuntimeError("input 'eps' or 'pdf' expected")
432 def pipeGS(self
, device
, input="eps", **kwargs
):
434 returns a BytesIO instance with the Ghostscript output of the EPS or PDF
437 cmd
, kwargs
= self
._gscmd
(device
, "-", **kwargs
)
439 with tempfile
.NamedTemporaryFile("wb", delete
=False) as f
:
441 self
.writeEPSfile(f
, **kwargs
)
443 self
.writePDFfile(f
, **kwargs
)
445 raise RuntimeError("input 'eps' or 'pdf' expected")
449 p
= config
.Popen(cmd
, stdout
=config
.PIPE
)
450 data
, error
= p
.communicate()
454 raise ValueError("error received while waiting for ghostscript")
455 return io
.BytesIO(data
)