use with statement for file operations (where appropriate)
[PyX.git] / pyx / canvas.py
blob7d31c2a628e2458db87236fb448c8e036181fc33
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, os, sys, string, tempfile, warnings
29 from . import attr, baseclasses, document, pycompat, style, trafo
30 from . import bbox as bboxmodule
32 def _wrappedindocument(method):
33 def wrappedindocument(self, file=None, **kwargs):
34 page_kwargs = {}
35 write_kwargs = {}
36 for name, value in list(kwargs.items()):
37 if name.startswith("page_"):
38 page_kwargs[name[5:]] = value
39 elif name.startswith("write_"):
40 write_kwargs[name[6:]] = value
41 else:
42 warnings.warn("Keyword argument %s of %s method should be prefixed with 'page_'" %
43 (name, method.__name__), DeprecationWarning)
44 page_kwargs[name] = value
45 d = document.document([document.page(self, **page_kwargs)])
46 self.__name__ = method.__name__
47 self.__doc__ = method.__doc__
48 return method(d, file, **write_kwargs)
49 return wrappedindocument
52 # clipping class
55 class clip(attr.attr):
57 """class for use in canvas constructor which clips to a path"""
59 def __init__(self, path):
60 """construct a clip instance for a given path"""
61 self.path = path
63 def processPS(self, file, writer, context, registry):
64 file.write("newpath\n")
65 self.path.outputPS(file, writer)
66 file.write("clip\n")
68 def processPDF(self, file, writer, context, registry):
69 self.path.outputPDF(file, writer)
70 file.write("W n\n")
74 # general canvas class
77 class canvas(baseclasses.canvasitem):
79 """a canvas holds a collection of canvasitems"""
81 def __init__(self, attrs=None, texrunner=None):
83 """construct a canvas
85 The canvas can be modfied by supplying a list of attrs, which have
86 to be instances of one of the following classes:
87 - trafo.trafo (leading to a global transformation of the canvas)
88 - canvas.clip (clips the canvas)
89 - style.strokestyle, style.fillstyle (sets some global attributes of the canvas)
91 Note that, while the first two properties are fixed for the
92 whole canvas, the last one can be changed via canvas.set().
94 The texrunner instance used for the text method can be specified
95 using the texrunner argument. It defaults to text.defaulttexrunner
97 """
99 self.items = []
100 self.trafo = trafo.identity
101 self.clip = None
102 self.layers = {}
103 if attrs is None:
104 attrs = []
105 if texrunner is not None:
106 self.texrunner = texrunner
107 else:
108 # prevent cyclic imports
109 from . import text
110 self.texrunner = text.defaulttexrunner
112 attr.checkattrs(attrs, [trafo.trafo_pt, clip, style.style])
113 attrs = attr.mergeattrs(attrs)
114 self.modifies_state = bool(attrs)
116 self.styles = attr.getattrs(attrs, [style.style])
118 # trafos (and one possible clip operation) are applied from left to
119 # right in the attrs list -> reverse for calculating total trafo
120 for aattr in reversed(attr.getattrs(attrs, [trafo.trafo_pt, clip])):
121 if isinstance(aattr, trafo.trafo_pt):
122 self.trafo = self.trafo * aattr
123 if self.clip is not None:
124 self.clip = clip(self.clip.path.transformed(aattr))
125 else:
126 if self.clip is not None:
127 raise ValueError("single clipping allowed only")
128 self.clip = aattr
130 def __len__(self):
131 return len(self.items)
133 def __getitem__(self, i):
134 return self.items[i]
136 def _repr_png_(self):
138 Automatically represent as PNG graphic when evaluated in IPython notebook.
140 return self.pipeGS(device="png16m", seekable=True).getvalue()
142 def bbox(self):
143 """returns bounding box of canvas
145 Note that this bounding box doesn't take into account the linewidths, so
146 is less accurate than the one used when writing the output to a file.
148 obbox = bboxmodule.empty()
149 for cmd in self.items:
150 obbox += cmd.bbox()
152 # transform according to our global transformation and
153 # intersect with clipping bounding box (which has already been
154 # transformed in canvas.__init__())
155 obbox.transform(self.trafo)
156 if self.clip is not None:
157 obbox *= self.clip.path.bbox()
158 return obbox
160 def processPS(self, file, writer, context, registry, bbox):
161 context = context()
162 if self.items:
163 if self.modifies_state:
164 file.write("gsave\n")
165 for attr in self.styles:
166 attr.processPS(file, writer, context, registry)
167 if self.trafo is not trafo.identity:
168 self.trafo.processPS(file, writer, context, registry)
169 if self.clip is not None:
170 self.clip.processPS(file, writer, context, registry)
171 nbbox = bboxmodule.empty()
172 for item in self.items:
173 item.processPS(file, writer, context, registry, nbbox)
174 # update bounding bbox
175 nbbox.transform(self.trafo)
176 if self.clip is not None:
177 nbbox *= self.clip.path.bbox()
178 bbox += nbbox
179 if self.modifies_state:
180 file.write("grestore\n")
182 def processPDF(self, file, writer, context, registry, bbox):
183 context = context()
184 textregion = False
185 context.trafo = context.trafo * self.trafo
186 if self.items:
187 if self.modifies_state:
188 file.write("q\n") # gsave
189 for attr in self.styles:
190 if isinstance(attr, style.fillstyle):
191 context.fillstyles.append(attr)
192 attr.processPDF(file, writer, context, registry)
193 if self.trafo is not trafo.identity:
194 self.trafo.processPDF(file, writer, context, registry)
195 if self.clip is not None:
196 self.clip.processPDF(file, writer, context, registry)
197 nbbox = bboxmodule.empty()
198 for item in self.items:
199 if not writer.text_as_path:
200 if item.requiretextregion():
201 if not textregion:
202 file.write("BT\n")
203 textregion = True
204 else:
205 if textregion:
206 file.write("ET\n")
207 textregion = False
208 context.selectedfont = None
209 item.processPDF(file, writer, context, registry, nbbox)
210 if textregion:
211 file.write("ET\n")
212 textregion = False
213 context.selectedfont = None
214 # update bounding bbox
215 nbbox.transform(self.trafo)
216 if self.clip is not None:
217 nbbox *= self.clip.path.bbox()
218 bbox += nbbox
219 if self.modifies_state:
220 file.write("Q\n") # grestore
222 def layer(self, name, above=None, below=None):
223 """create or get a layer with name
225 A layer is a canvas itself and can be used to combine drawing
226 operations for ordering purposes, i.e., what is above and below each
227 other. The layer name is a dotted string, where dots are used to form
228 a hierarchy of layer groups. When inserting a layer, it is put on top
229 of its layer group except when another layer of this group is specified
230 by means of the parameters above or below.
233 if above is not None and below is not None:
234 raise ValueError("above and below cannot be specified at the same time")
235 try:
236 group, layer = name.split(".", 1)
237 except ValueError:
238 if name in self.layers:
239 if above is not None or below is not None:
240 # remove for repositioning
241 self.items.remove(self.layers[name])
242 else:
243 # create new layer
244 self.layers[name] = canvas(texrunner=self.texrunner)
245 if above is None and below is None:
246 self.items.append(self.layers[name])
248 # (re)position layer
249 if above is not None:
250 self.items.insert(self.items.index(self.layers[above])+1, self.layers[name])
251 elif below is not None:
252 self.items.insert(self.items.index(self.layers[below]), self.layers[name])
254 return self.layers[name]
255 else:
256 if not group in self.layers:
257 self.layers[group] = self.insert(canvas(texrunner=self.texrunner))
258 if above is not None:
259 abovegroup, above = above.split(".", 1)
260 assert abovegroup == group
261 if below is not None:
262 belowgroup, below = below.split(".", 1)
263 assert belowgroup == group
264 return self.layers[group].layer(layer, above=above, below=below)
266 def insert(self, item, attrs=None):
267 """insert item in the canvas.
269 If attrs are passed, a canvas containing the item is inserted applying
270 attrs. If replace is not None, the new item is
271 positioned accordingly in the canvas.
273 returns the item, possibly wrapped in a canvas
277 if not isinstance(item, baseclasses.canvasitem):
278 raise ValueError("only instances of baseclasses.canvasitem can be inserted into a canvas")
280 if attrs:
281 sc = canvas(attrs)
282 sc.insert(item)
283 item = sc
285 self.items.append(item)
286 return item
288 def draw(self, path, attrs):
289 """draw path on canvas using the style given by args
291 The argument attrs consists of PathStyles, which modify
292 the appearance of the path, PathDecos, which add some new
293 visual elements to the path, or trafos, which are applied
294 before drawing the path.
297 from . import deco
298 attrs = attr.mergeattrs(attrs)
299 attr.checkattrs(attrs, [deco.deco, baseclasses.deformer, style.style])
301 for adeformer in attr.getattrs(attrs, [baseclasses.deformer]):
302 path = adeformer.deform(path)
304 styles = attr.getattrs(attrs, [style.style])
305 dp = deco.decoratedpath(path, styles=styles)
307 # add path decorations and modify path accordingly
308 for adeco in attr.getattrs(attrs, [deco.deco]):
309 adeco.decorate(dp, self.texrunner)
311 self.insert(dp)
313 def stroke(self, path, attrs=[]):
314 """stroke path on canvas using the style given by args
316 The argument attrs consists of PathStyles, which modify
317 the appearance of the path, PathDecos, which add some new
318 visual elements to the path, or trafos, which are applied
319 before drawing the path.
322 from . import deco
323 self.draw(path, [deco.stroked]+list(attrs))
325 def fill(self, path, attrs=[]):
326 """fill path on canvas using the style given by args
328 The argument attrs consists of PathStyles, which modify
329 the appearance of the path, PathDecos, which add some new
330 visual elements to the path, or trafos, which are applied
331 before drawing the path.
334 from . import deco
335 self.draw(path, [deco.filled]+list(attrs))
337 def settexrunner(self, texrunner):
338 """sets the texrunner to be used to within the text and text_pt methods"""
340 self.texrunner = texrunner
342 def text(self, x, y, atext, *args, **kwargs):
343 """insert a text into the canvas
345 inserts a textbox created by self.texrunner.text into the canvas
347 returns the inserted textbox"""
349 return self.insert(self.texrunner.text(x, y, atext, *args, **kwargs))
352 def text_pt(self, x, y, atext, *args):
353 """insert a text into the canvas
355 inserts a textbox created by self.texrunner.text_pt into the canvas
357 returns the inserted textbox"""
359 return self.insert(self.texrunner.text_pt(x, y, atext, *args))
361 writeEPSfile = _wrappedindocument(document.document.writeEPSfile)
362 writePSfile = _wrappedindocument(document.document.writePSfile)
363 writePDFfile = _wrappedindocument(document.document.writePDFfile)
364 writetofile = _wrappedindocument(document.document.writetofile)
367 def _gscmd(self, device, filename, resolution=100, gscmd="gs", gsoptions="",
368 textalphabits=4, graphicsalphabits=4, ciecolor=False, **kwargs):
370 allowed_chars = string.ascii_letters + string.digits + "_-./"
371 if filename.translate(str.maketrans({allowed_char: None for allowed_char in allowed_chars})):
372 raise ValueError("for security reasons, only characters, digits and the characters '_-./' are allowed in filenames")
374 gscmd += " -dEPSCrop -dNOPAUSE -dQUIET -dBATCH -r%i -sDEVICE=%s -sOutputFile=%s" % (resolution, device, filename)
375 if gsoptions:
376 gscmd += " %s" % gsoptions
377 if textalphabits is not None:
378 gscmd += " -dTextAlphaBits=%i" % textalphabits
379 if graphicsalphabits is not None:
380 gscmd += " -dGraphicsAlphaBits=%i" % graphicsalphabits
381 if ciecolor:
382 gscmd += " -dUseCIEColor"
384 return gscmd, kwargs
386 def writeGSfile(self, filename=None, device=None, input="eps", **kwargs):
388 convert EPS or PDF output to a file via Ghostscript
390 If filename is None it is auto-guessed from the script name. If
391 filename is "-", the output is written to stdout. In both cases, a
392 device needs to be specified to define the format.
394 If device is None, but a filename with suffix is given, PNG files will
395 be written using the png16m device and JPG files using the jpeg device.
397 if filename is None:
398 if not sys.argv[0].endswith(".py"):
399 raise RuntimeError("could not auto-guess filename")
400 if device.startswith("png"):
401 filename = sys.argv[0][:-2] + "png"
402 elif device.startswith("jpeg"):
403 filename = sys.argv[0][:-2] + "jpg"
404 else:
405 filename = sys.argv[0][:-2] + device
406 if device is None:
407 if filename.endswith(".png"):
408 device = "png16m"
409 elif filename.endswith(".jpg"):
410 device = "jpeg"
411 else:
412 raise RuntimeError("could not auto-guess device")
414 gscmd, kwargs = self._gscmd(device, filename, **kwargs)
416 if input == "eps":
417 gscmd += " -"
418 with pycompat.popen(gscmd, "wb") as stdin:
419 self.writeEPSfile(stdin, **kwargs)
420 elif input == "pdf":
421 # PDF files need to be accesible by random access and thus we need to create
422 # a temporary file
423 with tempfile.NamedTemporaryFile("wb", delete=False) as f:
424 gscmd += " %s" % f.name
425 self.writePDFfile(f, **kwargs)
426 fname = f.name
427 os.system(gscmd)
428 os.unlink(fname)
429 else:
430 raise RuntimeError("input 'eps' or 'pdf' expected")
433 def pipeGS(self, device, input="eps", seekable=False, **kwargs):
435 returns a pipe with the Ghostscript output of the EPS or PDF of the canvas
437 If seekable is True, a BytesIO instance will be returned instead of a
438 pipe to allow random access.
441 gscmd, kwargs = self._gscmd(device, "-", **kwargs)
443 if input == "eps":
444 gscmd += " -"
445 # we can safely ignore that the input and output pipes could block each other,
446 # because Ghostscript has to read the full input before writing the output
447 stdin, stdout = pycompat.popen2(gscmd)
448 self.writeEPSfile(stdin, **kwargs)
449 stdin.close()
450 elif input == "pdf":
451 # PDF files need to be accesible by random access and thus we need to create
452 # a temporary file
453 with tempfile.NamedTemporaryFile("wb", delete=False) as f:
454 gscmd += " %s" % f.name
455 self.writePDFfile(f, **kwargs)
456 fname = f.name
457 stdout = pycompat.popen(gscmd, "rb")
458 os.unlink(fname)
459 else:
460 raise RuntimeError("input 'eps' or 'pdf' expected")
462 if seekable:
463 # the read method of a pipe object may not return the full content
464 f = io.BytesIO()
465 while True:
466 data = stdout.read()
467 if not data:
468 break
469 f.write(data)
470 stdout.close()
471 f.seek(0)
472 return f
473 else:
474 return stdout