1#!/usr/local/bin/python3.8
2#
3# Copyright (C) 2016 su_v, <suv-sf@users.sf.net>
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18#
19"""
20Convert mesh gradient to path
21"""
22
23import inkex
24from inkex.elements import MeshGradient
25
26# globals
27EPSILON = 1e-3
28MG_PROPS = [
29    'fill',
30    'stroke'
31]
32
33def isclose(a, b, rel_tol=1e-09, abs_tol=0.0):
34    """Test approximate equality.
35
36    ref:
37        PEP 485 -- A Function for testing approximate equality
38        https://www.python.org/dev/peps/pep-0485/#proposed-implementation
39    """
40    # pylint: disable=invalid-name
41    return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
42
43
44def reverse_path(csp):
45    """Reverse path in CSP notation."""
46    rcsp = []
47    for subpath in reversed(csp):
48        rsub = [list(reversed(cp)) for cp in reversed(subpath)]
49        rcsp.append(rsub)
50    return rcsp
51
52
53def join_path(csp1, sp1, csp2, sp2):
54    """Join sub-paths *sp1* and *sp2*."""
55    pt1 = csp1[sp1][-1][1]
56    pt2 = csp2[sp2][0][1]
57    if (isclose(pt1[0], pt2[0], EPSILON) and
58            isclose(pt1[1], pt2[1], EPSILON)):
59        csp1[sp1][-1][2] = csp2[sp2][0][2]
60        csp1[sp1].extend(csp2[sp2][1:])
61    else:
62        # inkex.debug('not close')
63        csp1.append(csp2[sp2])
64    return csp1
65
66
67def is_url(val):
68    """Check whether attribute value is linked resource."""
69    return val.startswith('url(#')
70
71
72def mesh_corners(meshgradient):
73    """Return list of mesh patch corners, patch paths."""
74    rows = len(meshgradient)
75    cols = len(meshgradient[0])
76    # first corner of mesh gradient
77    corner_x = float(meshgradient.get('x', '0.0'))
78    corner_y = float(meshgradient.get('y', '0.0'))
79    # init corner and meshpatch lists
80    corners = [[None for _ in range(cols+1)] for _ in range(rows+1)]
81    corners[0][0] = [corner_x, corner_y]
82    meshpatch_csps = []
83    for meshrow in range(rows):
84        for meshpatch in range(cols):
85            # get start point for current meshpatch edges
86            if meshrow == 0:
87                first_corner = corners[meshrow][meshpatch]
88            if meshrow > 0:
89                first_corner = corners[meshrow][meshpatch+1]
90            # parse path of meshpatch edges
91            path = 'M {},{}'.format(*first_corner)
92            for edge in meshgradient[meshrow][meshpatch]:
93                path = ' '.join([path, edge.get('path')])
94            csp = inkex.Path(path).to_superpath()
95            # update corner list with current meshpatch
96            if meshrow == 0:
97                corners[meshrow][meshpatch+1] = csp[0][1][1]
98                corners[meshrow+1][meshpatch+1] = csp[0][2][1]
99                if meshpatch == 0:
100                    corners[meshrow+1][meshpatch] = csp[0][3][1]
101            if meshrow > 0:
102                corners[meshrow][meshpatch+1] = csp[0][0][1]
103                corners[meshrow+1][meshpatch+1] = csp[0][1][1]
104                if meshpatch == 0:
105                    corners[meshrow+1][meshpatch] = csp[0][2][1]
106            # append to list of meshpatch csp
107            meshpatch_csps.append(csp)
108    return corners, meshpatch_csps
109
110
111def mesh_hvlines(meshgradient):
112    """Return lists of vertical and horizontal patch edges."""
113    rows = len(meshgradient)
114    cols = len(meshgradient[0])
115    # init lists for horizontal, vertical lines
116    hlines = [[None for _ in range(cols)] for _ in range(rows+1)]
117    vlines = [[None for _ in range(rows)] for _ in range(cols+1)]
118    for meshrow in range(rows):
119        for meshpatch in range(cols):
120            # horizontal edges
121            if meshrow == 0:
122                edge = meshgradient[meshrow][meshpatch][0]
123                hlines[meshrow][meshpatch] = edge.get('path')
124                edge = meshgradient[meshrow][meshpatch][2]
125                hlines[meshrow+1][meshpatch] = edge.get('path')
126            if meshrow > 0:
127                edge = meshgradient[meshrow][meshpatch][1]
128                hlines[meshrow+1][meshpatch] = edge.get('path')
129            # vertical edges
130            if meshrow == 0:
131                edge = meshgradient[meshrow][meshpatch][1]
132                vlines[meshpatch+1][meshrow] = edge.get('path')
133                if meshpatch == 0:
134                    edge = meshgradient[meshrow][meshpatch][3]
135                    vlines[meshpatch][meshrow] = edge.get('path')
136            if meshrow > 0:
137                edge = meshgradient[meshrow][meshpatch][0]
138                vlines[meshpatch+1][meshrow] = edge.get('path')
139                if meshpatch == 0:
140                    edge = meshgradient[meshrow][meshpatch][2]
141                    vlines[meshpatch][meshrow] = edge.get('path')
142    return hlines, vlines
143
144
145def mesh_to_outline(corners, hlines, vlines):
146    """Construct mesh outline as CSP path."""
147    outline_csps = []
148    path = 'M {},{}'.format(*corners[0][0])
149    for edge_path in hlines[0]:
150        path = ' '.join([path, edge_path])
151    for edge_path in vlines[-1]:
152        path = ' '.join([path, edge_path])
153    for edge_path in reversed(hlines[-1]):
154        path = ' '.join([path, edge_path])
155    for edge_path in reversed(vlines[0]):
156        path = ' '.join([path, edge_path])
157    outline_csps.append(inkex.Path(path).to_superpath())
158    return outline_csps
159
160
161def mesh_to_grid(corners, hlines, vlines):
162    """Construct mesh grid with CSP paths."""
163    rows = len(corners) - 1
164    cols = len(corners[0]) - 1
165    gridline_csps = []
166    # horizontal
167    path = 'M {},{}'.format(*corners[0][0])
168    for edge_path in hlines[0]:
169        path = ' '.join([path, edge_path])
170    gridline_csps.append(inkex.Path(path).to_superpath())
171    for i in range(1, rows+1):
172        path = 'M {},{}'.format(*corners[i][-1])
173        for edge_path in reversed(hlines[i]):
174            path = ' '.join([path, edge_path])
175        gridline_csps.append(inkex.Path(path).to_superpath())
176    # vertical
177    path = 'M {},{}'.format(*corners[-1][0])
178    for edge_path in reversed(vlines[0]):
179        path = ' '.join([path, edge_path])
180    gridline_csps.append(inkex.Path(path).to_superpath())
181    for j in range(1, cols+1):
182        path = 'M {},{}'.format(*corners[0][j])
183        for edge_path in vlines[j]:
184            path = ' '.join([path, edge_path])
185        gridline_csps.append(inkex.Path(path).to_superpath())
186    return gridline_csps
187
188
189def mesh_to_faces(corners, hlines, vlines):
190    """Construct mesh faces with CSP paths."""
191    rows = len(corners) - 1
192    cols = len(corners[0]) - 1
193    face_csps = []
194    for row in range(rows):
195        for col in range(cols):
196            # init new face
197            face = []
198            # init edge paths
199            edge_t = hlines[row][col]
200            edge_b = hlines[row+1][col]
201            edge_l = vlines[col][row]
202            edge_r = vlines[col+1][row]
203            # top edge, first
204            if row == 0:
205                path = 'M {},{}'.format(*corners[row][col])
206                path = ' '.join([path, edge_t])
207                face.append(inkex.Path(path).to_superpath()[0])
208            else:
209                path = 'M {},{}'.format(*corners[row][col+1])
210                path = ' '.join([path, edge_t])
211                face.append(reverse_path(inkex.Path(path).to_superpath())[0])
212            # right edge
213            path = 'M {},{}'.format(*corners[row][col+1])
214            path = ' '.join([path, edge_r])
215            join_path(face, -1, inkex.Path(path).to_superpath(), 0)
216            # bottom edge
217            path = 'M {},{}'.format(*corners[row+1][col+1])
218            path = ' '.join([path, edge_b])
219            join_path(face, -1, inkex.Path(path).to_superpath(), 0)
220            # left edge
221            if col == 0:
222                path = 'M {},{}'.format(*corners[row+1][col])
223                path = ' '.join([path, edge_l])
224                join_path(face, -1, inkex.Path(path).to_superpath(), 0)
225            else:
226                path = 'M {},{}'.format(*corners[row][col])
227                path = ' '.join([path, edge_l])
228                join_path(face, -1, reverse_path(inkex.Path(path).to_superpath()), 0)
229            # append face to output list
230            face_csps.append(face)
231    return face_csps
232
233
234class MeshToPath(inkex.EffectExtension):
235    """Effect extension to convert mesh geometry to path data."""
236    def add_arguments(self, pars):
237        pars.add_argument("--tab", help="The selected UI-tab")
238        pars.add_argument("--mode", default="outline", help="Edge mode")
239
240    def process_props(self, mdict, res_type='meshgradient'):
241        """Process style properties of style dict *mdict*."""
242        result = []
243        for key, val in mdict.items():
244            if key in MG_PROPS:
245                if is_url(val):
246                    paint_server = self.svg.getElementById(val)
247                    if res_type == 'meshgradient' and isinstance(paint_server, MeshGradient):
248                        result.append(paint_server)
249        return result
250
251    def process_style(self, node, res_type='meshgradient'):
252        """Process style of *node*."""
253        result = []
254        # Presentation attributes
255        adict = dict(node.attrib)
256        result.extend(self.process_props(adict, res_type))
257        # Inline CSS style properties
258        result.extend(self.process_props(node.style, res_type))
259        # TODO: check for child paint servers
260        return result
261
262    def find_meshgradients(self, node):
263        """Parse node style, return list with linked meshgradients."""
264        return self.process_style(node, res_type='meshgradient')
265
266    # ----- Process meshgradient definitions
267
268    def mesh_to_csp(self, meshgradient):
269        """Parse mesh geometry and build csp-based path data."""
270
271        # init variables
272        transform = None
273        mode = self.options.mode
274
275        # gradient units
276        mesh_units = meshgradient.get('gradientUnits', 'objectBoundingBox')
277        if mesh_units == 'objectBoundingBox':
278            # TODO: position and scale based on "objectBoundingBox" units
279            return
280
281        # Inkscape SVG 0.92 and SVG 2.0 draft mesh transformations
282        transform = meshgradient.gradientTransform * meshgradient.transform
283
284        # parse meshpatches, calculate absolute corner coords
285        corners, meshpatch_csps = mesh_corners(meshgradient)
286
287        if mode == 'meshpatches':
288            return meshpatch_csps, transform
289        else:
290            hlines, vlines = mesh_hvlines(meshgradient)
291            if mode == 'outline':
292                return mesh_to_outline(corners, hlines, vlines), transform
293            elif mode == 'gridlines':
294                return mesh_to_grid(corners, hlines, vlines), transform
295            elif mode == 'faces':
296                return mesh_to_faces(corners, hlines, vlines), transform
297
298    # ----- Convert meshgradient definitions
299
300    def csp_to_path(self, node, csp_list, transform=None):
301        """Create new paths based on csp data, return group with paths."""
302        # set up stroke width, group
303        stroke_width = self.svg.unittouu('1px')
304        stroke_color = '#000000'
305        style = {
306            'fill': 'none',
307            'stroke': stroke_color,
308            'stroke-width': str(stroke_width),
309        }
310
311        group = inkex.Group()
312        # apply gradientTransform and node's preserved transform to group
313        group.transform = transform * node.transform
314
315        # convert each csp to path, append to group
316        for csp in csp_list:
317            elem = group.add(inkex.PathElement())
318            elem.style = style
319            elem.path = inkex.CubicSuperPath(csp)
320            if self.options.mode == 'outline':
321                elem.path.close()
322            elif self.options.mode == 'faces':
323                if len(csp) == 1 and len(csp[0]) == 5:
324                    elem.path.close()
325        return group
326
327    def effect(self):
328        """Main routine to convert mesh geometry to path data."""
329        # loop through selection
330        for node in self.svg.selected.values():
331            meshgradients = self.find_meshgradients(node)
332            # if style references meshgradient
333            if meshgradients:
334                for meshgradient in meshgradients:
335                    csp_list = None
336                    result = None
337                    # parse mesh geometry
338                    if meshgradient is not None:
339                        csp_list, mat = self.mesh_to_csp(meshgradient)
340                    # generate new paths with path data based on mesh geometry
341                    if csp_list is not None:
342                        result = self.csp_to_path(node, csp_list, mat)
343                    # add result (group) to document
344                    if result is not None:
345                        index = node.getparent().index(node)
346                        node.getparent().insert(index+1, result)
347
348
349if __name__ == '__main__':
350    MeshToPath().run()
351