prevent double call of _cleanup, which harms usefiles (and is a bad idea in general)
[PyX.git] / pyx / canvas.py
blobcb0383c054fcb302242623bf78e0e6f932ff5bb1
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
26 displayed. """
28 import io, logging, os, sys, string, tempfile
29 from . import attr, baseclasses, config, document, style, trafo, svgwriter, unit
30 from . import bbox as bboxmodule
32 logger = logging.getLogger("pyx")
34 def _wrappedindocument(method):
35 def wrappedindocument(self, file=None, **kwargs):
36 page_kwargs = {}
37 write_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
43 else:
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
54 # clipping class
57 class SVGclippath(svgwriter.SVGresource):
59 def __init__(self, path):
60 self.svgid = "clippath%d" % id(path)
61 super().__init__("clip-path", self.svgid)
62 self.path = path
64 def output(self, xml, writer, registry):
65 xml.startSVGElement("clipPath", {"id": self.svgid})
66 # TODO: clip-rule missing (defaults to nonzero)
67 xml.startSVGElement("path", {"d": self.path.returnSVGdata()})
68 xml.endSVGElement("path")
69 xml.endSVGElement("clipPath")
72 class clip(attr.attr):
74 """class for use in canvas constructor which clips to a path"""
76 def __init__(self, path):
77 """construct a clip instance for a given path"""
78 self.path = path
80 def processPS(self, file, writer, context, registry):
81 file.write("newpath\n")
82 self.path.outputPS(file, writer)
83 file.write("clip\n")
85 def processPDF(self, file, writer, context, registry):
86 self.path.outputPDF(file, writer)
87 file.write("W n\n")
89 def processSVGattrs(self, attrs, writer, context, registry):
90 clippath = SVGclippath(self.path)
91 registry.add(clippath)
92 attrs["clip-path"] = "url(#%s)" % clippath.svgid
96 # general canvas class
99 class canvas(baseclasses.canvasitem):
101 """a canvas holds a collection of canvasitems"""
103 def __init__(self, attrs=None, texrunner=None, ipython_bboxenlarge=1*unit.t_pt):
105 """construct a canvas
107 The canvas can be modfied by supplying a list of attrs, which have
108 to be instances of one of the following classes:
109 - trafo.trafo (leading to a global transformation of the canvas)
110 - canvas.clip (clips the canvas)
111 - style.strokestyle, style.fillstyle (sets some global attributes of the canvas)
113 Note that, while the first two properties are fixed for the
114 whole canvas, the last one can be changed via canvas.set().
116 The texrunner instance used for the text method can be specified
117 using the texrunner argument. It defaults to text.defaulttexrunner
121 self.items = []
122 self.trafo = trafo.identity
123 self.clip = None
124 self.layers = {}
125 if attrs is None:
126 attrs = []
127 if texrunner is not None:
128 self.texrunner = texrunner
129 else:
130 # prevent cyclic imports
131 from . import text
132 self.texrunner = text.defaulttexrunner
133 self.ipython_bboxenlarge = ipython_bboxenlarge
135 attr.checkattrs(attrs, [trafo.trafo_pt, clip, style.style])
136 attrs = attr.mergeattrs(attrs)
137 self.modifies_state = bool(attrs)
139 self.styles = attr.getattrs(attrs, [style.style])
141 # trafos (and one possible clip operation) are applied from left to
142 # right in the attrs list -> reverse for calculating total trafo
143 for aattr in reversed(attr.getattrs(attrs, [trafo.trafo_pt, clip])):
144 if isinstance(aattr, trafo.trafo_pt):
145 self.trafo = self.trafo * aattr
146 else:
147 if self.clip is not None:
148 raise ValueError("single clipping allowed only")
149 self.clip = clip(aattr.path.transformed(self.trafo))
151 def __len__(self):
152 return len(self.items)
154 def __getitem__(self, i):
155 return self.items[i]
157 def _repr_png_(self):
159 Automatically represent as PNG graphic when evaluated in IPython notebook.
161 return self.pipeGS(device="png16m", page_bboxenlarge=self.ipython_bboxenlarge).getvalue()
163 def _repr_svg_(self):
165 Automatically represent as SVG graphic when evaluated in IPython notebook.
167 f = io.BytesIO()
168 self.writeSVGfile(f, page_bboxenlarge=self.ipython_bboxenlarge)
169 return f.getvalue().decode("utf-8")
171 def bbox(self):
172 """returns bounding box of canvas
174 Note that this bounding box doesn't take into account the linewidths, so
175 is less accurate than the one used when writing the output to a file.
177 obbox = bboxmodule.empty()
178 for cmd in self.items:
179 obbox += cmd.bbox()
181 # transform according to our global transformation and
182 # intersect with clipping bounding box (which has already been
183 # transformed in canvas.__init__())
184 obbox.transform(self.trafo)
185 if self.clip is not None:
186 obbox *= self.clip.path.bbox()
187 return obbox
189 def processPS(self, file, writer, context, registry, bbox):
190 context = context()
191 if self.items:
192 if self.modifies_state:
193 file.write("gsave\n")
194 for attr in self.styles:
195 attr.processPS(file, writer, context, registry)
196 if self.clip is not None:
197 self.clip.processPS(file, writer, context, registry)
198 if self.trafo is not trafo.identity:
199 self.trafo.processPS(file, writer, context, registry)
200 nbbox = bboxmodule.empty()
201 for item in self.items:
202 item.processPS(file, writer, context, registry, nbbox)
203 # update bounding bbox
204 nbbox.transform(self.trafo)
205 if self.clip is not None:
206 nbbox *= self.clip.path.bbox()
207 bbox += nbbox
208 if self.modifies_state:
209 file.write("grestore\n")
211 def processPDF(self, file, writer, context, registry, bbox):
212 context = context()
213 textregion = False
214 context.trafo = context.trafo * self.trafo
215 if self.items:
216 if self.modifies_state:
217 file.write("q\n") # gsave
218 for attr in self.styles:
219 if isinstance(attr, style.fillstyle):
220 context.fillstyles.append(attr)
221 attr.processPDF(file, writer, context, registry)
222 if self.clip is not None:
223 self.clip.processPDF(file, writer, context, registry)
224 if self.trafo is not trafo.identity:
225 self.trafo.processPDF(file, writer, context, registry)
226 nbbox = bboxmodule.empty()
227 for item in self.items:
228 if not writer.text_as_path:
229 if item.requiretextregion():
230 if not textregion:
231 file.write("BT\n")
232 textregion = True
233 else:
234 if textregion:
235 file.write("ET\n")
236 textregion = False
237 context.selectedfont = None
238 item.processPDF(file, writer, context, registry, nbbox)
239 if textregion:
240 file.write("ET\n")
241 textregion = False
242 context.selectedfont = None
243 # update bounding bbox
244 nbbox.transform(self.trafo)
245 if self.clip is not None:
246 nbbox *= self.clip.path.bbox()
247 bbox += nbbox
248 if self.modifies_state:
249 file.write("Q\n") # grestore
251 def processSVG(self, xml, writer, context, registry, bbox):
252 if self.items:
253 if self.modifies_state:
254 context = context()
255 attrs = {}
256 for attr in self.styles:
257 attr.processSVGattrs(attrs, writer, context, registry)
258 if self.clip is not None:
259 self.clip.processSVGattrs(attrs, writer, context, registry)
260 if self.trafo is not trafo.identity:
261 # trafo needs to be applied after clipping
262 # thus write g and start anew
263 xml.startSVGElement("g", attrs)
264 attrs = {}
265 self.trafo.processSVGattrs(attrs, writer, context, registry)
266 elif self.trafo is not trafo.identity:
267 self.trafo.processSVGattrs(attrs, writer, context, registry)
268 xml.startSVGElement("g", attrs)
269 nbbox = bboxmodule.empty()
270 for item in self.items:
271 item.processSVG(xml, writer, context, registry, nbbox)
272 # update bounding bbox
273 nbbox.transform(self.trafo)
274 if self.clip is not None:
275 nbbox *= self.clip.path.bbox()
276 bbox += nbbox
277 if self.modifies_state:
278 xml.endSVGElement("g")
279 if self.clip is not None and self.trafo is not trafo.identity:
280 xml.endSVGElement("g")
282 def layer(self, name, above=None, below=None):
283 """create or get a layer with name
285 A layer is a canvas itself and can be used to combine drawing
286 operations for ordering purposes, i.e., what is above and below each
287 other. The layer name is a dotted string, where dots are used to form
288 a hierarchy of layer groups. When inserting a layer, it is put on top
289 of its layer group except when another layer of this group is specified
290 by means of the parameters above or below.
293 if above is not None and below is not None:
294 raise ValueError("above and below cannot be specified at the same time")
295 try:
296 group, layer = name.split(".", 1)
297 except ValueError:
298 if name in self.layers:
299 if above is not None or below is not None:
300 # remove for repositioning
301 self.items.remove(self.layers[name])
302 else:
303 # create new layer
304 self.layers[name] = canvas(texrunner=self.texrunner)
305 if above is None and below is None:
306 self.items.append(self.layers[name])
308 # (re)position layer
309 if above is not None:
310 self.items.insert(self.items.index(self.layers[above])+1, self.layers[name])
311 elif below is not None:
312 self.items.insert(self.items.index(self.layers[below]), self.layers[name])
314 return self.layers[name]
315 else:
316 if not group in self.layers:
317 self.layers[group] = self.insert(canvas(texrunner=self.texrunner))
318 if above is not None:
319 abovegroup, above = above.split(".", 1)
320 assert abovegroup == group
321 if below is not None:
322 belowgroup, below = below.split(".", 1)
323 assert belowgroup == group
324 return self.layers[group].layer(layer, above=above, below=below)
326 def insert(self, item, attrs=None):
327 """insert item in the canvas.
329 If attrs are passed, a canvas containing the item is inserted applying
330 attrs. If replace is not None, the new item is
331 positioned accordingly in the canvas.
333 returns the item, possibly wrapped in a canvas
337 if not isinstance(item, baseclasses.canvasitem):
338 raise ValueError("only instances of baseclasses.canvasitem can be inserted into a canvas")
340 if attrs:
341 sc = canvas(attrs)
342 sc.insert(item)
343 item = sc
345 self.items.append(item)
346 return item
348 def draw(self, path, attrs):
349 """draw path on canvas using the style given by args
351 The argument attrs consists of PathStyles, which modify
352 the appearance of the path, PathDecos, which add some new
353 visual elements to the path, or trafos, which are applied
354 before drawing the path.
357 from . import deco
358 attrs = attr.mergeattrs(attrs)
359 attr.checkattrs(attrs, [deco.deco, baseclasses.deformer, style.style])
361 for adeformer in attr.getattrs(attrs, [baseclasses.deformer]):
362 path = adeformer.deform(path)
364 styles = attr.getattrs(attrs, [style.style])
365 dp = deco.decoratedpath(path, styles=styles)
367 # add path decorations and modify path accordingly
368 for adeco in attr.getattrs(attrs, [deco.deco]):
369 adeco.decorate(dp, self.texrunner)
371 self.insert(dp)
373 def stroke(self, path, attrs=[]):
374 """stroke path on canvas using the style given by args
376 The argument attrs consists of PathStyles, which modify
377 the appearance of the path, PathDecos, which add some new
378 visual elements to the path, or trafos, which are applied
379 before drawing the path.
382 from . import deco
383 self.draw(path, [deco.stroked]+list(attrs))
385 def fill(self, path, attrs=[]):
386 """fill path on canvas using the style given by args
388 The argument attrs consists of PathStyles, which modify
389 the appearance of the path, PathDecos, which add some new
390 visual elements to the path, or trafos, which are applied
391 before drawing the path.
394 from . import deco
395 self.draw(path, [deco.filled]+list(attrs))
397 def settexrunner(self, texrunner):
398 """sets the texrunner to be used to within the text and text_pt methods"""
400 self.texrunner = texrunner
402 def text(self, x, y, atext, *args, **kwargs):
403 """insert a text into the canvas
405 inserts a textbox created by self.texrunner.text into the canvas
407 returns the inserted textbox"""
409 return self.insert(self.texrunner.text(x, y, atext, *args, **kwargs))
412 def text_pt(self, x, y, atext, *args):
413 """insert a text into the canvas
415 inserts a textbox created by self.texrunner.text_pt into the canvas
417 returns the inserted textbox"""
419 return self.insert(self.texrunner.text_pt(x, y, atext, *args))
421 writeEPSfile = _wrappedindocument(document.document.writeEPSfile)
422 writePSfile = _wrappedindocument(document.document.writePSfile)
423 writePDFfile = _wrappedindocument(document.document.writePDFfile)
424 writeSVGfile = _wrappedindocument(document.document.writeSVGfile)
425 writetofile = _wrappedindocument(document.document.writetofile)
428 def _gscmd(self, device, filename, resolution=100, gs="gs", gsoptions=[],
429 textalphabits=4, graphicsalphabits=4, ciecolor=False, **kwargs):
431 cmd = [gs, "-dEPSCrop", "-dNOPAUSE", "-dQUIET", "-dBATCH", "-r%d" % resolution, "-sDEVICE=%s" % device, "-sOutputFile=%s" % filename]
432 if textalphabits is not None:
433 cmd.append("-dTextAlphaBits=%i" % textalphabits)
434 if graphicsalphabits is not None:
435 cmd.append("-dGraphicsAlphaBits=%i" % graphicsalphabits)
436 if ciecolor:
437 cmd.append("-dUseCIEColor")
438 cmd.extend(gsoptions)
440 return cmd, kwargs
442 def writeGSfile(self, filename=None, device=None, input="eps", **kwargs):
444 convert EPS or PDF output to a file via Ghostscript
446 If filename is None it is auto-guessed from the script name. If
447 filename is "-", the output is written to stdout. In both cases, a
448 device needs to be specified to define the format.
450 If device is None, but a filename with suffix is given, PNG files will
451 be written using the png16m device and JPG files using the jpeg device.
453 if filename is None:
454 if not sys.argv[0].endswith(".py"):
455 raise RuntimeError("could not auto-guess filename")
456 if device.startswith("png"):
457 filename = sys.argv[0][:-2] + "png"
458 elif device.startswith("jpeg"):
459 filename = sys.argv[0][:-2] + "jpg"
460 else:
461 filename = sys.argv[0][:-2] + device
462 if device is None:
463 if filename.endswith(".png"):
464 device = "png16m"
465 elif filename.endswith(".jpg"):
466 device = "jpeg"
467 else:
468 raise RuntimeError("could not auto-guess device")
470 cmd, kwargs = self._gscmd(device, filename, **kwargs)
472 if input == "eps":
473 cmd.append("-")
474 p = config.Popen(cmd, stdin=config.PIPE)
475 self.writeEPSfile(p.stdin, **kwargs)
476 p.stdin.close()
477 p.wait()
478 elif input == "pdf":
479 # PDF files need to be accesible by random access and thus we need to create
480 # a temporary file
481 with tempfile.NamedTemporaryFile("wb", delete=False) as f:
482 self.writePDFfile(f, **kwargs)
483 fname = f.name
484 cmd.append(fname)
485 config.Popen(cmd).wait()
486 os.unlink(fname)
487 else:
488 raise RuntimeError("input 'eps' or 'pdf' expected")
491 def pipeGS(self, device, input="eps", **kwargs):
493 returns a BytesIO instance with the Ghostscript output of the EPS or PDF
496 cmd, kwargs = self._gscmd(device, "-", **kwargs)
498 with tempfile.NamedTemporaryFile("wb", delete=False) as f:
499 if input == "eps":
500 self.writeEPSfile(f, **kwargs)
501 elif input == "pdf":
502 self.writePDFfile(f, **kwargs)
503 else:
504 raise RuntimeError("input 'eps' or 'pdf' expected")
505 fname = f.name
507 cmd.append(fname)
508 p = config.Popen(cmd, stdout=config.PIPE)
509 data, error = p.communicate()
510 os.unlink(fname)
512 if error:
513 raise ValueError("error received while waiting for ghostscript")
514 return io.BytesIO(data)