allow for the calculation of the upper bound of arclen
[PyX.git] / canvas.py
blobbac5b4affa60012169592f3e19a5082edfe758dd
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
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 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"""
63 self.path = path
65 def processPS(self, file, writer, context, registry):
66 file.write("newpath\n")
67 self.path.outputPS(file, writer)
68 file.write("clip\n")
70 def processPDF(self, file, writer, context, registry):
71 self.path.outputPDF(file, writer)
72 file.write("W n\n")
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):
85 """construct a canvas
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
99 """
101 self.items = []
102 self.trafo = trafo.identity
103 self.clip = None
104 self.layers = {}
105 if attrs is None:
106 attrs = []
107 if texrunner is not None:
108 self.texrunner = texrunner
109 else:
110 # prevent cyclic imports
111 from . import text
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))
127 else:
128 if self.clip is not None:
129 raise ValueError("single clipping allowed only")
130 self.clip = aattr
132 def __len__(self):
133 return len(self.items)
135 def __getitem__(self, i):
136 return self.items[i]
138 def _repr_png_(self):
140 Automatically represent as PNG graphic when evaluated in IPython notebook.
142 return self.pipeGS(device="png16m").getvalue()
144 def bbox(self):
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:
152 obbox += cmd.bbox()
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()
160 return obbox
162 def processPS(self, file, writer, context, registry, bbox):
163 context = context()
164 if self.items:
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()
180 bbox += nbbox
181 if self.modifies_state:
182 file.write("grestore\n")
184 def processPDF(self, file, writer, context, registry, bbox):
185 context = context()
186 textregion = False
187 context.trafo = context.trafo * self.trafo
188 if self.items:
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():
203 if not textregion:
204 file.write("BT\n")
205 textregion = True
206 else:
207 if textregion:
208 file.write("ET\n")
209 textregion = False
210 context.selectedfont = None
211 item.processPDF(file, writer, context, registry, nbbox)
212 if textregion:
213 file.write("ET\n")
214 textregion = False
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()
220 bbox += nbbox
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")
237 try:
238 group, layer = name.split(".", 1)
239 except ValueError:
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])
244 else:
245 # create new layer
246 self.layers[name] = canvas(texrunner=self.texrunner)
247 if above is None and below is None:
248 self.items.append(self.layers[name])
250 # (re)position layer
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]
257 else:
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")
282 if attrs:
283 sc = canvas(attrs)
284 sc.insert(item)
285 item = sc
287 self.items.append(item)
288 return 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.
299 from . import deco
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)
313 self.insert(dp)
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.
324 from . import deco
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.
336 from . import deco
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)
377 if ciecolor:
378 cmd.append("-dUseCIEColor")
379 cmd.extend(gsoptions)
381 return cmd, kwargs
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.
394 if filename is None:
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"
401 else:
402 filename = sys.argv[0][:-2] + device
403 if device is None:
404 if filename.endswith(".png"):
405 device = "png16m"
406 elif filename.endswith(".jpg"):
407 device = "jpeg"
408 else:
409 raise RuntimeError("could not auto-guess device")
411 cmd, kwargs = self._gscmd(device, filename, **kwargs)
413 if input == "eps":
414 cmd.append("-")
415 p = config.Popen(cmd, stdin=config.PIPE)
416 self.writeEPSfile(p.stdin, **kwargs)
417 p.stdin.close()
418 p.wait()
419 elif input == "pdf":
420 # PDF files need to be accesible by random access and thus we need to create
421 # a temporary file
422 with tempfile.NamedTemporaryFile("wb", delete=False) as f:
423 self.writePDFfile(f, **kwargs)
424 fname = f.name
425 cmd.append(fname)
426 config.Popen(cmd).wait()
427 os.unlink(fname)
428 else:
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:
440 if input == "eps":
441 self.writeEPSfile(f, **kwargs)
442 elif input == "pdf":
443 self.writePDFfile(f, **kwargs)
444 else:
445 raise RuntimeError("input 'eps' or 'pdf' expected")
446 fname = f.name
448 cmd.append(fname)
449 p = config.Popen(cmd, stdout=config.PIPE)
450 data, error = p.communicate()
451 os.unlink(fname)
453 if error:
454 raise ValueError("error received while waiting for ghostscript")
455 return io.BytesIO(data)