1# Copyright (C) 2010 Jeremy S. Sanders 2# Email: Jeremy Sanders <jeremy@jeremysanders.net> 3# 4# This program is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation; either version 2 of the License, or 7# (at your option) any later version. 8# 9# This program is distributed in the hope that it will be useful, 10# but WITHOUT ANY WARRANTY; without even the implied warranty of 11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12# GNU General Public License for more details. 13# 14# You should have received a copy of the GNU General Public License along 15# with this program; if not, write to the Free Software Foundation, Inc., 16# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 17############################################################################## 18 19"""A home-brewed SVG paint engine for doing svg with clipping 20and exporting text as paths for WYSIWYG.""" 21 22from __future__ import division, print_function 23import re 24 25from ..compat import crange, cbytes 26from .. import qtall as qt 27 28# physical sizes 29inch_mm = 25.4 30inch_pt = 72.0 31 32def printpath(path): 33 """Debugging print path.""" 34 print("Contents of", path) 35 for i in crange(path.elementCount()): 36 el = path.elementAt(i) 37 print(" ", el.type, el.x, el.y) 38 39def fltStr(v, prec=2): 40 """Change a float to a string, using a maximum number of decimal places 41 but removing trailing zeros.""" 42 43 # ensures consistent rounding behaviour on different platforms 44 v = round(v, prec+2) 45 46 val = ('% 20.10f' % v)[:10+prec] 47 48 # drop any trailing zeros 49 val = val.rstrip('0').lstrip(' ').rstrip('.') 50 # get rid of -0s (platform differences here) 51 if val == '-0': 52 val = '0' 53 return val 54 55def escapeXML(text): 56 """Escape special characters in XML.""" 57 # we have swap & with an unused character, so we can replace it later 58 text = text.replace('&', u'\ue001') 59 text = text.replace('<', '<') 60 text = text.replace('>', '>') 61 text = text.replace('"', '"') 62 text = text.replace("'", ''') 63 text = text.replace(u'\ue001', '&') 64 return text 65 66def createPath(path, scale): 67 """Convert qt path to svg path. 68 69 We use relative coordinates to make the file size smaller and help 70 compression 71 """ 72 p = [] 73 count = path.elementCount() 74 i = 0 75 ox, oy = 0, 0 76 while i < count: 77 e = path.elementAt(i) 78 nx, ny = e.x*scale, e.y*scale 79 if e.type == qt.QPainterPath.MoveToElement: 80 p.append( 'm%s,%s' % (fltStr(nx-ox), fltStr(ny-oy)) ) 81 ox, oy = nx, ny 82 elif e.type == qt.QPainterPath.LineToElement: 83 p.append( 'l%s,%s' % (fltStr(nx-ox), fltStr(ny-oy)) ) 84 ox, oy = nx, ny 85 elif e.type == qt.QPainterPath.CurveToElement: 86 e1 = path.elementAt(i+1) 87 e2 = path.elementAt(i+2) 88 p.append( 'c%s,%s,%s,%s,%s,%s' % ( 89 fltStr(nx-ox), fltStr(ny-oy), 90 fltStr(e1.x*scale-ox), fltStr(e1.y*scale-oy), 91 fltStr(e2.x*scale-ox), fltStr(e2.y*scale-oy)) ) 92 ox, oy = e2.x*scale, e2.y*scale 93 i += 2 94 else: 95 assert False 96 97 i += 1 98 return ''.join(p) 99 100class SVGElement(object): 101 """SVG element in output. 102 This represents the XML tree in memory 103 """ 104 105 def __init__(self, parent, eltype, attrb, text=None): 106 """Intialise element. 107 parent: parent element or None 108 eltype: type (e.g. 'polyline') 109 attrb: attribute string appended to output 110 text: text to output between this and closing element. 111 """ 112 self.eltype = eltype 113 self.attrb = attrb 114 self.children = [] 115 self.parent = parent 116 self.text = text 117 118 if parent: 119 parent.children.append(self) 120 121 def write(self, fileobj): 122 """Write element and its children to the output file.""" 123 fileobj.write('<%s' % self.eltype) 124 if self.attrb: 125 fileobj.write(' ' + self.attrb) 126 127 if self.text: 128 fileobj.write('>%s</%s>\n' % (self.text, self.eltype)) 129 elif self.children: 130 fileobj.write('>\n') 131 for c in self.children: 132 c.write(fileobj) 133 fileobj.write('</%s>\n' % self.eltype) 134 else: 135 # simple close tag if not children or text 136 fileobj.write('/>\n') 137 138class SVGPaintEngine(qt.QPaintEngine): 139 """Paint engine class for writing to svg files.""" 140 141 def __init__(self, writetextastext=False): 142 qt.QPaintEngine.__init__( 143 self, 144 qt.QPaintEngine.Antialiasing | 145 qt.QPaintEngine.PainterPaths | 146 qt.QPaintEngine.PrimitiveTransform | 147 qt.QPaintEngine.PaintOutsidePaintEvent | 148 qt.QPaintEngine.PixmapTransform | 149 qt.QPaintEngine.AlphaBlend 150 ) 151 152 self.imageformat = 'png' 153 self.writetextastext = writetextastext 154 155 def begin(self, paintdevice): 156 """Start painting.""" 157 self.device = paintdevice 158 self.scale = paintdevice.scale 159 160 self.pen = qt.QPen() 161 self.brush = qt.QBrush() 162 self.clippath = None 163 self.clipnum = 0 164 self.existingclips = {} 165 self.transform = qt.QTransform() 166 167 # svg root element for qt defaults 168 self.rootelement = SVGElement( 169 None, 'svg', 170 ('width="%spx" height="%spx" version="1.1"\n' 171 ' xmlns="http://www.w3.org/2000/svg"\n' 172 ' xmlns:xlink="http://www.w3.org/1999/xlink"') % ( 173 fltStr(self.device.width*self.device.sdpi*self.scale), 174 fltStr(self.device.height*self.device.sdpi*self.scale)) 175 ) 176 SVGElement(self.rootelement, 'desc', '', 'Veusz output document') 177 178 # definitions, for clips, etc. 179 self.defs = SVGElement(self.rootelement, 'defs', '') 180 181 # this is where all the drawing goes 182 self.celement = SVGElement( 183 self.rootelement, 'g', 184 'stroke-linejoin="bevel" stroke-linecap="square" ' 185 'stroke="#000000" fill-rule="evenodd"') 186 187 # previous transform, stroke and clip states 188 self.oldstate = [None, None, None] 189 190 # cache paths to avoid duplication 191 self.pathcache = {} 192 self.pathcacheidx = 0 193 194 return True 195 196 def pruneEmptyGroups(self): 197 """Take the element tree and remove any empty group entries.""" 198 199 def recursive(root): 200 children = list(root.children) 201 # remove any empty children first 202 for c in children: 203 recursive(c) 204 if root.eltype == 'g' and len(root.children) == 0: 205 # safe to remove 206 index = root.parent.children.index(root) 207 del root.parent.children[index] 208 209 # merge equal groups 210 last = None 211 i = 0 212 while i < len(root.children): 213 this = root.children[i] 214 if ( last is not None and 215 last.eltype == this.eltype and last.attrb == this.attrb 216 and last.text == this.text ): 217 last.children += this.children 218 del root.children[i] 219 else: 220 last = this 221 i += 1 222 223 recursive(self.rootelement) 224 225 def end(self): 226 self.pruneEmptyGroups() 227 228 fileobj = self.device.fileobj 229 fileobj.write('<?xml version="1.0" standalone="no"?>\n' 230 '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n' 231 ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n') 232 233 # write all the elements 234 self.rootelement.write(fileobj) 235 236 return True 237 238 def _updateClipPath(self, clippath, clipoperation): 239 """Update clip path given state change.""" 240 241 clippath = self.transform.map(clippath) 242 243 if clipoperation == qt.Qt.NoClip: 244 self.clippath = None 245 elif clipoperation == qt.Qt.ReplaceClip: 246 self.clippath = clippath 247 elif clipoperation == qt.Qt.IntersectClip: 248 self.clippath = self.clippath.intersected(clippath) 249 elif clipoperation == qt.Qt.UniteClip: 250 self.clippath = self.clippath.united(clippath) 251 else: 252 assert False 253 254 def updateState(self, state): 255 """Examine what has changed in state and call apropriate function.""" 256 ss = state.state() 257 258 # state is a list of transform, stroke/fill and clip states 259 statevec = list(self.oldstate) 260 if ss & qt.QPaintEngine.DirtyTransform: 261 self.transform = state.transform() 262 statevec[0] = self.transformState() 263 if ss & qt.QPaintEngine.DirtyPen: 264 self.pen = state.pen() 265 statevec[1] = self.strokeFillState() 266 if ss & qt.QPaintEngine.DirtyBrush: 267 self.brush = state.brush() 268 statevec[1] = self.strokeFillState() 269 if ss & qt.QPaintEngine.DirtyClipPath: 270 self._updateClipPath(state.clipPath(), state.clipOperation()) 271 statevec[2] = self.clipState() 272 if ss & qt.QPaintEngine.DirtyClipRegion: 273 path = qt.QPainterPath() 274 path.addRegion(state.clipRegion()) 275 self._updateClipPath(path, state.clipOperation()) 276 statevec[2] = self.clipState() 277 278 # work out which state differs first 279 pop = 0 280 for i in crange(2, -1, -1): 281 if statevec[i] != self.oldstate[i]: 282 pop = i+1 283 break 284 285 # go back up the tree the required number of times 286 for i in crange(pop): 287 if self.oldstate[i]: 288 self.celement = self.celement.parent 289 290 # create new elements for changed states 291 for i in crange(pop-1, -1, -1): 292 if statevec[i]: 293 self.celement = SVGElement( 294 self.celement, 'g', ' '.join(statevec[i])) 295 296 self.oldstate = statevec 297 298 def clipState(self): 299 """Get SVG clipping state. This is in the form of an svg group""" 300 301 if self.clippath is None: 302 return () 303 304 path = createPath(self.clippath, self.scale) 305 306 if path in self.existingclips: 307 url = 'url(#c%i)' % self.existingclips[path] 308 else: 309 clippath = SVGElement(self.defs, 'clipPath', 310 'id="c%i"' % self.clipnum) 311 SVGElement(clippath, 'path', 'd="%s"' % path) 312 url = 'url(#c%i)' % self.clipnum 313 self.existingclips[path] = self.clipnum 314 self.clipnum += 1 315 316 return ('clip-path="%s"' % url,) 317 318 def strokeFillState(self): 319 """Return stroke-fill state.""" 320 321 vals = {} 322 p = self.pen 323 # - color 324 color = p.color().name() 325 if color != '#000000': 326 vals['stroke'] = p.color().name() 327 # - opacity 328 if p.color().alphaF() != 1.: 329 vals['stroke-opacity'] = '%.3g' % p.color().alphaF() 330 # - join style 331 if p.joinStyle() != qt.Qt.BevelJoin: 332 vals['stroke-linejoin'] = { 333 qt.Qt.MiterJoin: 'miter', 334 qt.Qt.SvgMiterJoin: 'miter', 335 qt.Qt.RoundJoin: 'round', 336 qt.Qt.BevelJoin: 'bevel' 337 }[p.joinStyle()] 338 # - cap style 339 if p.capStyle() != qt.Qt.SquareCap: 340 vals['stroke-linecap'] = { 341 qt.Qt.FlatCap: 'butt', 342 qt.Qt.SquareCap: 'square', 343 qt.Qt.RoundCap: 'round' 344 }[p.capStyle()] 345 # - width 346 w = p.widthF() 347 # width 0 is device width for qt 348 if w == 0.: 349 w = 1./self.scale 350 vals['stroke-width'] = fltStr(w*self.scale) 351 352 # - line style 353 if p.style() == qt.Qt.NoPen: 354 vals['stroke'] = 'none' 355 elif p.style() not in (qt.Qt.SolidLine, qt.Qt.NoPen): 356 # convert from pen width fractions to pts 357 nums = [fltStr(self.scale*w*x) for x in p.dashPattern()] 358 vals['stroke-dasharray'] = ','.join(nums) 359 360 # BRUSH STYLES 361 b = self.brush 362 if b.style() == qt.Qt.NoBrush: 363 vals['fill'] = 'none' 364 else: 365 vals['fill'] = b.color().name() 366 if b.color().alphaF() != 1.0: 367 vals['fill-opacity'] = '%.3g' % b.color().alphaF() 368 369 items = ['%s="%s"' % x for x in sorted(vals.items())] 370 return tuple(items) 371 372 def transformState(self): 373 if not self.transform.isIdentity(): 374 m = self.transform 375 dx, dy = m.dx(), m.dy() 376 if (m.m11(), m.m12(), m.m21(), m.m22()) == (1., 0., 0., 1): 377 out = ('transform="translate(%s,%s)"' % ( 378 fltStr(dx*self.scale), fltStr(dy*self.scale)) ,) 379 else: 380 out = ('transform="matrix(%s %s %s %s %s %s)"' % ( 381 fltStr(m.m11(), 4), fltStr(m.m12(), 4), 382 fltStr(m.m21(), 4), fltStr(m.m22(), 4), 383 fltStr(dx*self.scale), fltStr(dy*self.scale) ),) 384 else: 385 out = () 386 return out 387 388 def drawPath(self, path): 389 """Draw a path on the output.""" 390 p = createPath(path, self.scale) 391 392 attrb = 'd="%s"' % p 393 if path.fillRule() == qt.Qt.WindingFill: 394 attrb += ' fill-rule="nonzero"' 395 396 if attrb in self.pathcache: 397 element, num = self.pathcache[attrb] 398 if num is None: 399 # this is the first time an element has been referenced again 400 # assign it an id for use below 401 num = self.pathcacheidx 402 self.pathcacheidx += 1 403 self.pathcache[attrb] = element, num 404 # add an id attribute 405 element.attrb += ' id="p%i"' % num 406 407 # if the parent is a translation, swallow this into the use element 408 m = re.match(r'transform="translate\(([-0-9.]+),([-0-9.]+)\)"', 409 self.celement.attrb) 410 if m: 411 SVGElement(self.celement.parent, 'use', 412 'xlink:href="#p%i" x="%s" y="%s"' % ( 413 num, m.group(1), m.group(2))) 414 else: 415 SVGElement(self.celement, 'use', 'xlink:href="#p%i"' % num) 416 else: 417 pathel = SVGElement(self.celement, 'path', attrb) 418 self.pathcache[attrb] = [pathel, None] 419 420 def drawTextItem(self, pt, textitem): 421 """Convert text to a path and draw it. 422 """ 423 424 if self.writetextastext: 425 # size 426 f = textitem.font() 427 if f.pixelSize() > 0: 428 size = f.pixelSize()*self.scale 429 else: 430 size = f.pointSizeF()*self.scale*self.device.sdpi/inch_pt 431 432 font = textitem.font() 433 grpattrb = [ 434 'stroke="none"', 435 'fill="%s"' % self.pen.color().name(), 436 'fill-opacity="%.3g"' % self.pen.color().alphaF(), 437 'font-family="%s"' % escapeXML(font.family()), 438 'font-size="%s"' % size, 439 ] 440 if font.italic(): 441 grpattrb.append('font-style="italic"') 442 if font.bold(): 443 grpattrb.append('font-weight="bold"') 444 445 grp = SVGElement( 446 self.celement, 'g', 447 ' '.join(grpattrb) ) 448 449 text = escapeXML( textitem.text() ) 450 451 textattrb = [ 452 'x="%s"' % fltStr(pt.x()*self.scale), 453 'y="%s"' % fltStr(pt.y()*self.scale), 454 'textLength="%s"' % fltStr(textitem.width()*self.scale), 455 ] 456 457 # spaces get lost without this 458 if text.find(' ') >= 0 or text[:1] == ' ' or text[-1:] == ' ': 459 textattrb.append('xml:space="preserve"') 460 461 # write as an SVG text element 462 SVGElement( 463 grp, 'text', 464 ' '.join(textattrb), 465 text=text ) 466 467 else: 468 # convert to a path 469 path = qt.QPainterPath() 470 path.addText(pt, textitem.font(), textitem.text()) 471 p = createPath(path, self.scale) 472 SVGElement( 473 self.celement, 'path', 474 'd="%s" fill="%s" stroke="none" fill-opacity="%.3g"' % ( 475 p, self.pen.color().name(), self.pen.color().alphaF()) ) 476 477 def drawLines(self, lines): 478 """Draw multiple lines.""" 479 paths = [] 480 for line in lines: 481 path = 'M%s,%sl%s,%s' % ( 482 fltStr(line.x1()*self.scale), 483 fltStr(line.y1()*self.scale), 484 fltStr((line.x2()-line.x1())*self.scale), 485 fltStr((line.y2()-line.y1())*self.scale)) 486 paths.append(path) 487 SVGElement(self.celement, 'path', 'd="%s"' % ''.join(paths)) 488 489 def drawPolygon(self, points, mode): 490 """Draw polygon on output.""" 491 pts = [] 492 for p in points: 493 pts.append( '%s,%s' % (fltStr(p.x()*self.scale), fltStr(p.y()*self.scale)) ) 494 495 if mode == qt.QPaintEngine.PolylineMode: 496 SVGElement(self.celement, 'polyline', 497 'fill="none" points="%s"' % ' '.join(pts)) 498 499 else: 500 attrb = 'points="%s"' % ' '.join(pts) 501 if mode == qt.Qt.WindingFill: 502 attrb += ' fill-rule="nonzero"' 503 SVGElement(self.celement, 'polygon', attrb) 504 505 def drawEllipse(self, rect): 506 """Draw an ellipse to the svg file.""" 507 SVGElement(self.celement, 'ellipse', 508 'cx="%s" cy="%s" rx="%s" ry="%s"' % 509 (fltStr(rect.center().x()*self.scale), 510 fltStr(rect.center().y()*self.scale), 511 fltStr(rect.width()*0.5*self.scale), 512 fltStr(rect.height()*0.5*self.scale))) 513 514 def drawPoints(self, points): 515 """Draw points.""" 516 for pt in points: 517 x, y = fltStr(pt.x()*self.scale), fltStr(pt.y()*self.scale) 518 SVGElement(self.celement, 'line', 519 ('x1="%s" y1="%s" x2="%s" y2="%s" ' 520 'stroke-linecap="round"') % (x, y, x, y)) 521 522 def drawImage(self, r, img, sr, flags): 523 """Draw image. 524 As the pixmap method uses the same code, just call this.""" 525 self.drawPixmap(r, img, sr) 526 527 def drawPixmap(self, r, pixmap, sr): 528 """Draw pixmap svg item. 529 530 This is converted to a bitmap and embedded in the output 531 """ 532 533 # convert pixmap to textual data 534 data = qt.QByteArray() 535 buf = qt.QBuffer(data) 536 buf.open(qt.QBuffer.ReadWrite) 537 pixmap.save(buf, self.imageformat.upper(), 0) 538 buf.close() 539 540 attrb = [ 'x="%s" y="%s" ' % (fltStr(r.x()*self.scale), fltStr(r.y()*self.scale)), 541 'width="%s" ' % fltStr(r.width()*self.scale), 542 'height="%s" ' % fltStr(r.height()*self.scale), 543 'xlink:href="data:image/%s;base64,' % self.imageformat, 544 cbytes(data.toBase64()).decode('ascii'), 545 '" preserveAspectRatio="none"' ] 546 SVGElement(self.celement, 'image', ''.join(attrb)) 547 548 def type(self): 549 """A random number for the engine.""" 550 return qt.QPaintEngine.User + 11 551 552class SVGPaintDevice(qt.QPaintDevice): 553 """Paint device for SVG paint engine. 554 555 dpi is the real output DPI (unscaled) 556 scale is a scaling value to apply to outputted values 557 """ 558 559 def __init__(self, fileobj, width_in, height_in, 560 writetextastext=False, dpi=90, scale=0.1): 561 qt.QPaintDevice.__init__(self) 562 self.fileobj = fileobj 563 self.width = width_in 564 self.height = height_in 565 self.scale = scale 566 self.sdpi = dpi/scale 567 self.engine = SVGPaintEngine(writetextastext=writetextastext) 568 569 def paintEngine(self): 570 return self.engine 571 572 def metric(self, m): 573 """Return the metrics of the painter.""" 574 575 if m == qt.QPaintDevice.PdmWidth: 576 return int(self.width*self.sdpi) 577 elif m == qt.QPaintDevice.PdmHeight: 578 return int(self.height*self.sdpi) 579 elif m == qt.QPaintDevice.PdmWidthMM: 580 return int(self.engine.width*inch_mm) 581 elif m == qt.QPaintDevice.PdmHeightMM: 582 return int(self.engine.height*inch_mm) 583 elif m == qt.QPaintDevice.PdmNumColors: 584 return 2147483647 585 elif m == qt.QPaintDevice.PdmDepth: 586 return 24 587 elif m == qt.QPaintDevice.PdmDpiX: 588 return int(self.sdpi) 589 elif m == qt.QPaintDevice.PdmDpiY: 590 return int(self.sdpi) 591 elif m == qt.QPaintDevice.PdmPhysicalDpiX: 592 return int(self.sdpi) 593 elif m == qt.QPaintDevice.PdmPhysicalDpiY: 594 return int(self.sdpi) 595 elif m == qt.QPaintDevice.PdmDevicePixelRatio: 596 return 1 597 598 # Qt >= 5.6 599 elif m == getattr(qt.QPaintDevice, 'PdmDevicePixelRatioScaled', -1): 600 return 1 601 602 else: 603 # fall back 604 return qt.QPaintDevice.metric(self, m) 605