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('<', '&lt;')
60    text = text.replace('>', '&gt;')
61    text = text.replace('"', '&quot;')
62    text = text.replace("'", '&apos;')
63    text = text.replace(u'\ue001', '&amp;')
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