1
2 """
3 Drawing is a module to generate Encapsulated PostScript or SVG images.
4
5 Drawing exports a simple procedural API which can be used to generate paths
6 which are then stroked or filled. Drawings created using this API can be
7 exported in any one of the supported formats, although some features (such
8 as those for scriptability) may be ignored for some formats.
9 """
10 from __future__ import division, with_statement
11 import StringIO, math, warnings, re
12
14 """BBox is a utility class which encapsulates a bounding box."""
16 self.border = 0
17 """We can also track maximum linewidths within the bounding box, and
18 expand it by the appropriate amount."""
19 self.minx, self.miny, self.maxx, self.maxy = (None,None,None,None)
21 """Make sure the bounding box is big enough to include the given x,y
22 point."""
23 if self.minx is None:
24 self.minx, self.miny, self.maxx, self.maxy = (x,y,x,y)
25 else:
26 self.minx = min (x, self.minx)
27 self.miny = min (y, self.miny)
28 self.maxx = max (x, self.maxx)
29 self.maxy = max (y, self.maxy)
31 """Make sure the bounding box is big enough to include the given
32 bounding box. Note that the border is not increased by this method;
33 instead the given bounding box is expanded to include its border
34 before being added to this."""
35 if isinstance(bbox, BBox):
36 bbox = bbox.bbox()
37
38 self.update(bbox[0], bbox[1])
39 self.update(bbox[2], bbox[3])
41 """Keep track of the largest linewidth we've seen."""
42 self.border = max(self.border, linewidth)
44 """Return `True` if there's been at least one update to this `BBox`."""
45 return self.minx is not None
47 return str(self.bbox())
49 """Return a bounding box (as a 4-tuple) guaranteed to encompass this
50 entire object."""
51 if not self: return None
52 return (self.minx - self.border, self.miny - self.border,
53 self.maxx + self.border, self.maxy + self.border)
54
56 """OutputLanguage abstracts the drawing backend by providing abstract
57 canvas operations.
58
59 This is the parent class for the specific backends; it should not be
60 instantiated directly."""
62 self.symbols = StringIO.StringIO()
63 self.patterns = StringIO.StringIO()
64 self.main = StringIO.StringIO()
65 self.out = self.main
67 """Translate a block of drawing commands by the given x and y offsets.
68
69 For example::
70
71 with out.translate(10,10):
72 with out.stroke() as path:
73 path.moveto(0,0)
74 path.lineto(5,5)
75 """
76 return self.group().translate(x, y)
78 """Rotate a block of drawing commands by the given angle CCW
79 around the origin.
80
81 For example::
82
83 with out.rotate(45):
84 with out.stroke() as path:
85 path.moveto(0,0)
86 path.lineto(5,5)
87 """
88 return self.group().rotate(theta)
90 """Return a path which will be stroked.
91
92 For example::
93
94 out.setgray(.5)
95 out.setlinewidth(2)
96 with out.stroke() as path:
97 path.moveto(0, 0)
98 path.lineto(5, 5)
99 """
100 return self.path().stroke()
102 """Return a path which will be filled.
103
104 For example::
105
106 out.setgray(.5)
107 with out.fill() as path:
108 path.moveto(0, 0)
109 path.lineto(5, 5)
110 path.lineto(10, 0)
111 path.closepath()
112 """
113 return self.path().fill()
114
116 """Write an uninterpreted raw string to the current output target."""
117 self.out.write(str)
118
119 -class PostScript (OutputLanguage):
120 """PostScript backend."""
121 - def __init__ (self):
122 OutputLanguage.__init__ (self)
123 self._clearState()
124 self._bbox = BBox()
126 return self._bbox.bbox()
127 - def _clearState (self):
128 self.state = { 'linewidth': None, 'linecap': None, 'font': None, 'color': None, 'transform': None }
129 - def view (self, bbox, **ignore):
133 - def setlinewidth (self, width):
134 if not self.state['transform']: self._bbox.updateBorder(width)
135 if width != self.state['linewidth']:
136 self.write ('%s setlinewidth\n' % width)
137 self.state['linewidth'] = width
138 - def setrgbcolor (self, r, g, b):
139 if (r, g, b) != self.state['color']:
140 self.write ('%s %s %s setrgbcolor\n' % (r, g, b))
141 self.state['color'] = (r, g, b)
142 - def setgray (self, gray):
143 if gray != self.state['color']:
144 self.write ('%s setgray\n' % gray)
145 self.state['color'] = gray
146 - def setgrayorrgb (self, x):
147 if isinstance (x, tuple):
148 self.setrgbcolor (*x)
149 else:
150 self.setgray (x)
151 - def setlinecap (self, linecap):
152 if linecap != self.state['linecap']:
153 mapping = { 'butt': 0, 'round': 1, 'square': 2 }
154 assert mapping.has_key(linecap)
155 self.state['linecap'] = linecap
156 self.write ('%s setlinecap\n' % mapping[linecap])
157 - def path (self, **ignore):
158
159 class PathMgr(object):
160 def __init__ (_self):
161 _self.cmd = ''
162 def __enter__ (_self):
163 return _self
164 def __exit__ (_self, type, value, traceback):
165 self.write (_self.cmd)
166 def fill (_self):
167 _self.cmd += 'fill\n'
168 return _self
169 def stroke (_self):
170 _self.cmd += 'stroke\n'
171 return _self
172 def moveto (_self, x, y):
173 self.write ('%s %s moveto\n' % (x, y))
174 if not self.state['transform']: self._bbox.update (x, y)
175 def lineto (_self, x, y):
176 self.write ('%s %s lineto\n' % (x, y))
177 if not self.state['transform']: self._bbox.update (x, y)
178 def closepath (_self):
179 self.write ('closepath\n')
180 def circle (_self, x, y, r):
181 self.write ('%s %s %s 0 360 arc\n' % (x, y, r))
182 self._bbox.update (x - r, y - r)
183 self._bbox.update (x + r, y + r)
184 def arc (_self, x, y, r, s, t):
185 self.write ('%s %s %s %s %s arc\n' % (x, y, r, s, t))
186 self._bbox.update (x - r, y - r)
187 self._bbox.update (x + r, y + r)
188 def arcn (_self, x, y, r, s, t):
189 self.write ('%s %s %s %s %s arcn\n' % (x, y, r, s, t))
190 self._bbox.update (x - r, y - r)
191 self._bbox.update (x + r, y + r)
192 return PathMgr()
193 - def setfont (self, font, scale):
194 if (font, scale) != self.state['font']:
195 self.write ('/%s findfont %s scalefont setfont\n' % (font, scale))
196 self.state['font'] = (font, scale)
197 - def text (self, x, y, text, **ignore):
198 self.write ('%s %s moveto (%s) show\n' % (x, y, self.esc(text)))
199 if not self.state['transform']: self._bbox.update (x,y)
200 - def group (self, **ignore):
201
202 class TransMgr(object):
203 def __init__(_self):
204 _self.cmd = ''
205 def __enter__(_self):
206 if _self.cmd:
207 self.write('gsave\n')
208 self.write(_self.cmd)
209 _self.state = self.state.copy()
210 self.state['transform'] = _self.cmd
211 def __exit__(_self, type, value, traceback):
212 if _self.cmd:
213 self.write('grestore\n')
214 self.state = _self.state
215 def translate (_self, x, y):
216 if x!=0 or y!=0:
217 _self.cmd += '%s %s translate\n' % (x, y)
218 return _self
219 def rotate(_self, theta):
220 if theta != 0:
221 _self.cmd += '%s rotate\n' % theta
222 return _self
223 def scale(_self, x, y):
224 if x != 1 or y != 1:
225 _self.cmd += '%s %s scale\n' % (x, y)
226 return _self
227 return TransMgr()
228 - def defineSymbol (self, name):
229 """Define a stored procedure named 'name'.
230
231 with out.defineSymbol('foo'):
232 out.moveto(0,0)
233 # etc
234 """
235 class SymMgr(object):
236 def __enter__ (_self):
237 _self.old_out = self.out
238 _self.old_state = self.state
239 self._clearState()
240 self.out = StringIO.StringIO()
241 self.write('/%s {\n' % self.escId(name))
242 self.write('gsave translate\n')
243 def __exit__ (_self, type, value, traceback):
244 self.write('grestore\n')
245 self.write('} def\n')
246 self.symbols.write(self.out.getvalue ())
247 self.out = _self.old_out
248 self.state = _self.old_state
249 return SymMgr ()
250 - def refSymbol (self, name, x, y):
251 self.write('%s %s %s\n' % (x, y, self.escId(name)))
252 - def esc (self, str):
253 """Escape a string so that it is safe for postscript."""
254 return str.replace ('\\', '\\\\').replace ('(', '\\(') \
255 .replace (')', '\\)').replace ('\n', '\\n')
256 - def escId (self, str):
257 """Make a valid postscript entity from an arbitrary string."""
258 def _esc (c):
259 assert len(c)==1
260 return '!%02X' % ord(c[0])
261 def _escMO (mo): return _esc(mo.group(0))
262 r = re.sub(r'[\[\]()<>{}/%\000\011\012\014\015\040!]', _escMO, str)
263 if r and re.match(r'[-+0-9.]', r):
264 r = _esc([r[0]]) + r[1:]
265 return r
266 - def __str__ (self):
267 bbox = self._bbox.bbox()
268 return '''\
269 %%!PS-Adobe-3.0
270 %%%%BoundingBox: %d %d %d %d
271 %%%%HiResBoundingBox: %s %s %s %s
272 %s
273 %s
274 showpage
275 %%%%EOF
276 ''' % (bbox[0], bbox[1], bbox[2], bbox[3],
277 bbox[0], bbox[1], bbox[2], bbox[3],
278 self.symbols.getvalue (),
279 self.main.getvalue ())
280
281
283 """Convert an integer to a string using the given radix."""
284 if num < 0: return '-' + _intToStr(-num, radix)
285 if num < radix: return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[num]
286 return _intToStr(num // radix,radix) + _intToStr(num % radix,radix)
287
288 -class SVG (OutputLanguage):
289 """SVG backend."""
290 - def __init__ (self, docTitle, version='1.1', useCSS=True):
291 OutputLanguage.__init__(self)
292 self.docTitle = docTitle
293 self.version = version
294 self.useCSS = useCSS
295 self.scriptOut = StringIO.StringIO()
296 self.styleOut = StringIO.StringIO()
297 self.styles = {}
298 self.nextStyle = 0
299 self._clearState()
301 self.state = { 'color': 'black', 'linewidth': 1, 'linecap': 'round',
302 'font-family': None, 'font-size': None, 'transform': None,
303 'bbox': BBox(), 'nesting': 0 }
305 """Return a bounding box around the contents of the current active group.
306 """
307 return self.state['bbox'].bbox()
309 all = attribs.copy()
310 all.update(addlAttribs)
311 if all.has_key('classes'):
312 all['class'] = all['classes']
313 del all['classes']
314
315 if not self.useCSS:
316 return self._mkAttrib(**all)
317
318
319 blacklist = dict([ (k,v) for k,v in all.iteritems()
320 if k in ('id', 'class', 'x', 'y', 'transform',
321 'xlink:href' ) ])
322 for k in blacklist.keys(): del all[k]
323
324 key = frozenset(all.iteritems())
325 if len(key)==0:
326 nclass = ''
327 elif self.styles.has_key(key):
328 nclass = self.styles[key]
329 else:
330 nclass = ':' + _intToStr(self.nextStyle, radix=36)
331 self.nextStyle += 1
332 self.styles[key] = nclass
333 self.styleOut.write('[class~="%s"] { %s }\n' % (nclass, '; '.join([('%s: %s'%(k,v)) for k,v in all.iteritems()])))
334
335 if nclass:
336 blacklist['class'] = (blacklist.get('class','')+' '+nclass).strip()
337 return self._mkAttrib(**blacklist)
338
340 """SVG-specific method: generate a reference which will load the specified
341 script when this SVG drawing is viewed."""
342 self.scriptOut.write('<script type="text/ecmascript" xlink:href="%s" />'
343 % scriptURL)
344 - def view (self, bbox, id=None, viewTarget=None):
355 self.state['linewidth'] = width
357 self.state['color'] = 'rgb(%s%%,%s%%,%s%%)' % (100*r, 100*g, 100*b)
366 assert linecap in ('butt','round','square')
367 self.state['linecap'] = linecap
368 - def path (self, **attribs):
369 class PathMgr(object):
370 def __init__ (_self):
371 _self.cmd = {}
372 _self.path = StringIO.StringIO()
373 _self.curpos = None
374 _self.bbox = BBox()
375 def __enter__ (_self):
376 return _self
377 def __exit__ (_self, type, value, traceback):
378 if not _self.cmd.has_key('fill'): _self.cmd['fill'] = 'none'
379 if not _self.cmd.has_key('stroke'): _self.cmd['stroke'] = 'none'
380 self.indent ('<path%s d="%s"/>'
381 % (self._cachedAttribs(_self.cmd, **attribs),
382 _self.path.getvalue ()))
383 if not self.state['transform']:
384 self.state['bbox'].updateBBox(_self.bbox)
385 def fill (_self):
386 _self.cmd['fill'] = self.state['color']
387 return _self
388 def stroke (_self):
389 _self.bbox.updateBorder(self.state['linewidth'])
390 _self.cmd.update({'stroke': self.state['color'],
391 'stroke-width': self.state['linewidth'],
392 'stroke-linecap': self.state['linecap'] })
393 return _self
394 def moveto (_self, x, y, bbox = True):
395 _self.curpos = (x, y)
396 if not self.state['transform']: _self.bbox.update(x, y)
397 def lineto (_self, x, y, bbox = True):
398 if _self.curpos is not None:
399 _self.path.write('M %s %s ' % (_self.curpos[0], _self.curpos[1]))
400 _self.curpos = None
401 _self.path.write('L %s %s ' % (x, y))
402 if not self.state['transform']: _self.bbox.update(x, y)
403 def closepath (_self):
404 _self.path.write ('Z ')
405 def psArc (_self, x, y, r, s, t, ccw):
406 _self.bbox.update (x - r, y - r)
407 _self.bbox.update (x + r, y + r)
408
409
410 sx = x + r*math.cos(math.radians(s))
411 sy = y + r*math.sin(math.radians(s))
412 tx = x + r*math.cos(math.radians(t))
413 ty = y + r*math.sin(math.radians(t))
414 largeArc = 1 if (math.fabs(s-t) > 180) else 0
415 sweepFlag = 1 if ccw else 0
416 if _self.curpos is not None:
417 _self.path.write('M %s %s L ' % (_self.curpos[0], _self.curpos[1]))
418 _self.curpos = None
419 else:
420 _self.path.write('M ')
421 _self.path.write('%s %s A %s,%s %s %s,%s %s,%s ' %
422 (sx, sy, r, r, 0, largeArc, sweepFlag, tx, ty))
423 def circle (_self, x, y, r):
424 _self.psArc(x, y, r, 0, 180, False)
425 _self.psArc(x, y, r, 180, 360, False)
426 def arc (_self, x, y, r, s, t):
427 while t < s:
428 t += 360
429 _self.psArc (x, y, r, s, t, True)
430 def arcn (_self, x, y, r, s, t):
431 while t > s:
432 t -= 360
433 _self.psArc (x, y, r, s, t, False)
434 return PathMgr()
435
437 if font == 'Times-Roman':
438 font = 'Verdana,sans-serif'
439 self.state['font-family'] = font
440 self.state['font-size'] = scale
441 - def text (self, x, y, text, bbox=True, **attribs):
442 with self.translate(x,y).scale(1,-1):
443 self.indent('<text%s>%s</text>' %
444 (self._cachedAttribs(
445 { 'x':0, 'y':0, 'font-family': self.state['font-family'],
446
447 'font-size': str(self.state['font-size'])+'px',
448 'fill': self.state['color'] },
449 **attribs), self.esc(text)))
450
451 if not self.state['transform']: self.state['bbox'].update (x,y)
452 - def group (self, link=None, view_id=None, **attribs):
453 el = 'g'
454 if link:
455 el = 'a'
456 attribs['xlink:href']=link
457 if view_id:
458 view_target = attribs.get('id', 'view:'+view_id)
459 class TransMgr(object):
460 def __init__(_self):
461 _self.cmd = ''
462 def __enter__(_self):
463 _self.state = self.state.copy()
464 self.state['bbox'] = BBox()
465 if _self.cmd:
466 attribs['transform'] = _self.cmd
467 self.state['transform'] = _self.cmd
468 self.indent('<%s%s>' % (el, self._mkAttrib(**attribs)))
469 self.state['nesting'] += 1
470 def __exit__(_self, type, value, traceback):
471 bbox = self.state['bbox'].bbox()
472
473 if view_id:
474 self.view(bbox, id=view_id, viewTarget=view_target)
475 self.state = _self.state
476 self.indent('</%s>' % el)
477
478 if bbox: self.state['bbox'].updateBBox(bbox)
479 def translate (_self, x, y):
480 if x!=0 or y!=0:
481 _self.cmd += 'translate(%s,%s) ' % (x, y)
482 return _self
483 def rotate(_self, theta):
484 if theta != 0:
485 _self.cmd += 'rotate(%s) ' % theta
486 return _self
487 def scale(_self, xScale, yScale):
488 if xScale != 1 or yScale != 1:
489 _self.cmd += 'scale(%s,%s) ' % (xScale,yScale)
490 return _self
491 return TransMgr()
492
494 """Define a symbol named 'name'.
495
496 with out.defineSymbol('foo'):
497 out.moveto(0,0)
498 # etc
499 """
500 class SymMgr(object):
501 def __init__ (_self):
502 _self.group = self.group(id=self.escId(name))
503 def __enter__ (_self):
504 _self.old_out = self.out
505 _self.old_state = self.state
506 self._clearState()
507 self.out = StringIO.StringIO()
508
509
510 _self.group.__enter__()
511 def __exit__ (_self, type, value, traceback):
512 _self.group.__exit__(type, value, traceback)
513 self.symbols.write(self.out.getvalue ())
514 self.out = _self.old_out
515
516 self.state = _self.old_state
517 return SymMgr ()
523 r = ''
524 for key,value in attribs.iteritems():
525 if key == 'classes': key = 'class'
526 if value is not None:
527 r += ' %s="%s"' % (key, self.esc(str(value)))
528 return r
529 - def esc (self, str):
530 """Perform appropriate XML content escaping for the given string."""
531 def _esc (mo):
532 c = mo.group(0)
533 assert len(c)==1
534 return '&#%d;' % ord(c[0])
535 return re.sub(r'[\"\'&<>\\]', _esc, str)
537 """Return a value suitable for an XML 'id' given an arbitrary string."""
538 def _esc (c):
539 assert len(c)==1
540 return '_%02X' % ord(c[0])
541 def _escMO (mo): return _esc(mo.group(0))
542
543 r = re.sub(r'[^-A-Za-z0-9.]', _escMO, str)
544 if r and not re.match(r'[A-Za-z_:]', r):
545 r = _esc([r[0]]) + r[1:]
546 return r
548 self.write(' '*self.state['nesting'])
549 self.write(str)
550 self.write('\n')
552 """Return an SVG document."""
553 assert self.state['nesting'] == 0
554 bbox = self.state['bbox'].bbox()
555 width = (bbox[2] - bbox[0])
556 height = (bbox[3] - bbox[1])
557
558 header = ('''\
559 <?xml version="1.0" encoding="UTF-8"?>
560 <svg width="%s" height="%s" version="1.2" baseProfile="tiny"
561 xmlns="http://www.w3.org/2000/svg"
562 xmlns:xlink="http://www.w3.org/1999/xlink">
563 ''' if self.version == '1.2 Tiny' else '''\
564 <?xml version="1.0" standalone="no"?>
565 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
566 "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
567 <svg width="%s" height="%s" version="1.1"
568 xmlns="http://www.w3.org/2000/svg"
569 xmlns:xlink="http://www.w3.org/1999/xlink">
570 ''') % (width, height)
571
572 return header + '''\
573 <title>Khipu %s</title>
574 %s
575 <style type="text/css"><![CDATA[
576 %s
577 ]]></style>
578 <defs>
579 %s
580 </defs>
581 <g transform="scale(1,-1) translate(%s,%s)">
582 %s
583 </g>
584 </svg>
585 ''' % (self.docTitle,
586 self.scriptOut.getvalue (),
587 self.styleOut.getvalue (),
588 self.symbols.getvalue (),
589 -bbox[0], -bbox[3],
590 self.main.getvalue())
591