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
, svgwriter
, unit
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 SVGclippath(svgwriter
.SVGresource
):
59 def __init__(self
, path
):
60 self
.svgid
= "clippath%d" % id(path
)
61 super().__init
__("clip-path", self
.svgid
)
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"""
80 def processPS(self
, file, writer
, context
, registry
):
81 file.write("newpath\n")
82 self
.path
.outputPS(file, writer
)
85 def processPDF(self
, file, writer
, context
, registry
):
86 self
.path
.outputPDF(file, writer
)
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
122 self
.trafo
= trafo
.identity
127 if texrunner
is not None:
128 self
.texrunner
= texrunner
130 # prevent cyclic imports
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
147 if self
.clip
is not None:
148 raise ValueError("single clipping allowed only")
149 self
.clip
= clip(aattr
.path
.transformed(self
.trafo
))
152 return len(self
.items
)
154 def __getitem__(self
, 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.
168 self
.writeSVGfile(f
, page_bboxenlarge
=self
.ipython_bboxenlarge
)
169 return f
.getvalue().decode("utf-8")
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
:
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()
189 def processPS(self
, file, writer
, context
, registry
, bbox
):
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()
208 if self
.modifies_state
:
209 file.write("grestore\n")
211 def processPDF(self
, file, writer
, context
, registry
, bbox
):
214 context
.trafo
= context
.trafo
* self
.trafo
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():
237 context
.selectedfont
= None
238 item
.processPDF(file, writer
, context
, registry
, nbbox
)
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()
248 if self
.modifies_state
:
249 file.write("Q\n") # grestore
251 def processSVG(self
, xml
, writer
, context
, registry
, bbox
):
253 if self
.modifies_state
:
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
)
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()
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")
296 group
, layer
= name
.split(".", 1)
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
])
304 self
.layers
[name
] = canvas(texrunner
=self
.texrunner
)
305 if above
is None and below
is None:
306 self
.items
.append(self
.layers
[name
])
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
]
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")
345 self
.items
.append(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.
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
)
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.
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.
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
)
437 cmd
.append("-dUseCIEColor")
438 cmd
.extend(gsoptions
)
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.
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"
461 filename
= sys
.argv
[0][:-2] + device
463 if filename
.endswith(".png"):
465 elif filename
.endswith(".jpg"):
468 raise RuntimeError("could not auto-guess device")
470 cmd
, kwargs
= self
._gscmd
(device
, filename
, **kwargs
)
474 p
= config
.Popen(cmd
, stdin
=config
.PIPE
)
475 self
.writeEPSfile(p
.stdin
, **kwargs
)
479 # PDF files need to be accesible by random access and thus we need to create
481 with tempfile
.NamedTemporaryFile("wb", delete
=False) as f
:
482 self
.writePDFfile(f
, **kwargs
)
485 config
.Popen(cmd
).wait()
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
:
500 self
.writeEPSfile(f
, **kwargs
)
502 self
.writePDFfile(f
, **kwargs
)
504 raise RuntimeError("input 'eps' or 'pdf' expected")
508 p
= config
.Popen(cmd
, stdout
=config
.PIPE
)
509 data
, error
= p
.communicate()
513 raise ValueError("error received while waiting for ghostscript")
514 return io
.BytesIO(data
)