Code

Extensions. Text support improvement in XAML and FXG export.
[inkscape.git] / share / extensions / pixelsnap.py
1 #!/usr/bin/env python
3 """
4 TODO: This only snaps selected elements, and if those elements are part of a
5     group or layer that has it's own transform, that won't be taken into
6     account, unless you snap the group or layer as a whole. This can account
7     for unexpected results in some cases (eg where you've got a non-integer
8     translation on the layer you're working in, the elements in that layer
9     won't snap properly). The workaround for now is to snap the whole
10     group/layer, or remove the transform on the group/layer.
11     
12     I could fix it in the code by traversing the parent elements up to the
13     document root & calculating the cumulative parent_transform. This could
14     be done at the top of the pixel_snap method if parent_transform==None,
15     or before calling it for the first time.
17 TODO: Transforming points isn't quite perfect, to say the least. In particular,
18     when translating a point bezier curve, we translate the handles by the same amount.
19     BUT, some handles that are attached to a particular point are conceptually
20     handles of the prev/next node.
21     Best way to fix it would be to keep a list of the fractional_offsets[] of
22     each point, without transforming anything. Then go thru each point and
23     transform the appropriate handle according to the relevant fraction_offset
24     in the list.
25     
26     i.e. calculate first, then modify.
27     
28     In fact, that might be a simpler algorithm anyway -- it avoids having
29     to keep track of all the first_xy/next_xy guff.
31 TODO: make elem_offset return [x_offset, y_offset] so we can handle non-symetric scaling
33 ------------
35 Note: This doesn't work very well on paths which have both straight segments
36       and curved segments.
37       The biggest three problems are:
38         a) we don't take handles into account (segments where the nodes are
39            aligned are always treated as straight segments, even where the
40            handles make it curve)
41         b) when we snap a straight segment right before/after a curve, it
42            doesn't make any attempt to keep the transition from the straight
43            segment to the curve smooth.
44         c) no attempt is made to keep equal widths equal. (or nearly-equal
45            widths nearly-equal). For example, font strokes.
46         
47     I guess that amounts to the problyem that font hinting solves for fonts.
48     I wonder if I could find an automatic font-hinting algorithm and munge
49     it to my purposes?
50     
51     Some good autohinting concepts that may help:
52     http://freetype.sourceforge.net/autohinting/archive/10Mar2000/hinter.html
54 Note: Paths that have curves & arcs on some sides of the bounding box won't
55     be snapped correctly on that side of the bounding box, and nor will they
56     be translated/resized correctly before the path is modified. Doesn't affect
57     most applications of this extension, but it highlights the fact that we
58     take a geometrically simplistic approach to inspecting & modifying the path.
59 """
61 from __future__ import division
63 import sys
64 # *** numpy causes issue #4 on Mac OS 10.6.2. I use it for
65 # matrix inverse -- my linear algebra's a bit rusty, but I could implement my
66 # own matrix inverse function if necessary, I guess.
67 from numpy import matrix
68 import simplestyle, simpletransform, simplepath
70 # INKEX MODULE
71 # If you get the "No module named inkex" error, uncomment the relevant line
72 # below by removing the '#' at the start of the line.
73 #
74 #sys.path += ['/usr/share/inkscape/extensions']                     # If you're using a standard Linux installation
75 #sys.path += ['/usr/local/share/inkscape/extensions']               # If you're using a custom Linux installation
76 #sys.path += ['C:\\Program Files\\Inkscape\\share\\extensions']     # If you're using a standard Windows installation
78 try:
79     import inkex
80     from inkex import unittouu
81 except ImportError:
82     raise ImportError("No module named inkex.\nPlease edit the file %s and see the section titled 'INKEX MODULE'" % __file__)
84 Precision = 5                   # number of digits of precision for comparing float numbers
86 MaxGradient = 1/200             # lines that are almost-but-not-quite straight will be snapped, too.
88 class TransformError(Exception): pass
90 def elemtype(elem, matches):
91     if not isinstance(matches, (list, tuple)): matches = [matches]
92     for m in matches:
93         if elem.tag == inkex.addNS(m, 'svg'): return True
94     return False
96 def invert_transform(transform):
97     transform = transform[:]    # duplicate list to avoid modifying it
98     transform += [[0, 0, 1]]
99     inverse = matrix(transform).I.tolist()
100     inverse.pop()
101     return inverse
103 def transform_point(transform, pt, inverse=False):
104     """ Better than simpletransform.applyTransformToPoint,
105         a) coz it's a simpler name
106         b) coz it returns the new xy, rather than modifying the input
107     """
108     if inverse:
109         transform = invert_transform(transform)
110     
111     x = transform[0][0]*pt[0] + transform[0][1]*pt[1] + transform[0][2]
112     y = transform[1][0]*pt[0] + transform[1][1]*pt[1] + transform[1][2]
113     return x,y
115 def transform_dimensions(transform, width=None, height=None, inverse=False):
116     """ Dimensions don't get translated. I'm not sure how much diff rotate/skew
117         makes in this context, but we currently ignore anything besides scale.
118     """
119     if inverse: transform = invert_transform(transform)
121     if width is not None: width *= transform[0][0]
122     if height is not None: height *= transform[1][1]
123     
124     if width is not None and height is not None: return width, height
125     if width is not None: return width
126     if height is not None: return height
129 def vertical(pt1, pt2):
130     hlen = abs(pt1[0] - pt2[0])
131     vlen = abs(pt1[1] - pt2[1])
132     if vlen==0 and hlen==0:
133         return True
134     elif vlen==0:
135         return False
136     return (hlen / vlen) < MaxGradient
138 def horizontal(pt1, pt2):
139     hlen = round(abs(pt1[0] - pt2[0]), Precision)
140     vlen = round(abs(pt1[1] - pt2[1]), Precision)
141     if hlen==0 and vlen==0:
142         return True
143     elif hlen==0:
144         return False
145     return (vlen / hlen) < MaxGradient
147 class PixelSnapEffect(inkex.Effect):
148     def elem_offset(self, elem, parent_transform=None):
149         """ Returns a value which is the amount the
150             bounding-box is offset due to the stroke-width.
151             Transform is taken into account.
152         """
153         stroke_width = self.stroke_width(elem)
154         if stroke_width == 0: return 0                                          # if there's no stroke, no need to worry about the transform
156         transform = self.transform(elem, parent_transform=parent_transform)
157         if abs(abs(transform[0][0]) - abs(transform[1][1])) > (10**-Precision):
158             raise TransformError("Selection contains non-symetric scaling")     # *** wouldn't be hard to get around this by calculating vertical_offset & horizontal_offset separately, maybe 2 functions, or maybe returning a tuple
160         stroke_width = transform_dimensions(transform, width=stroke_width)
162         return (stroke_width/2)
164     def stroke_width(self, elem, setval=None):
165         """ Return stroke-width in pixels, untransformed
166         """
167         style = simplestyle.parseStyle(elem.attrib.get('style', ''))
168         stroke = style.get('stroke', None)
169         if stroke == 'none': stroke = None
170             
171         stroke_width = 0
172         if stroke and setval is None:
173             stroke_width = unittouu(style.get('stroke-width', '').strip())
174             
175         if setval:
176             style['stroke-width'] = str(setval)
177             elem.attrib['style'] = simplestyle.formatStyle(style)
178         else:
179             return stroke_width
181     def snap_stroke(self, elem, parent_transform=None):
182         transform = self.transform(elem, parent_transform=parent_transform)
184         stroke_width = self.stroke_width(elem)
185         if (stroke_width == 0): return                                          # no point raising a TransformError if there's no stroke to snap
187         if abs(abs(transform[0][0]) - abs(transform[1][1])) > (10**-Precision):
188             raise TransformError("Selection contains non-symetric scaling, can't snap stroke width")
189         
190         if stroke_width:
191             stroke_width = transform_dimensions(transform, width=stroke_width)
192             stroke_width = round(stroke_width)
193             stroke_width = transform_dimensions(transform, width=stroke_width, inverse=True)
194             self.stroke_width(elem, stroke_width)
196     def transform(self, elem, setval=None, parent_transform=None):
197         """ Gets this element's transform. Use setval=matrix to
198             set this element's transform.
199             You can only specify parent_transform when getting.
200         """
201         transform = elem.attrib.get('transform', '').strip()
202         
203         if transform:
204             transform = simpletransform.parseTransform(transform)
205         else:
206             transform = [[1,0,0], [0,1,0], [0,0,1]]
207         if parent_transform:
208             transform = simpletransform.composeTransform(parent_transform, transform)
209             
210         if setval:
211             elem.attrib['transform'] = simpletransform.formatTransform(setval)
212         else:
213             return transform
215     def snap_transform(self, elem):
216         # Only snaps the x/y translation of the transform, nothing else.
217         # Scale transforms are handled only in snap_rect()
218         # Doesn't take any parent_transform into account -- assumes
219         # that the parent's transform has already been snapped.
220         transform = self.transform(elem)
221         if transform[0][1] or transform[1][0]: return           # if we've got any skew/rotation, get outta here
222  
223         transform[0][2] = round(transform[0][2])
224         transform[1][2] = round(transform[1][2])
225         
226         self.transform(elem, transform)
227     
228     def transform_path_node(self, transform, path, i):
229         """ Modifies a segment so that every point is transformed, including handles
230         """
231         segtype = path[i][0].lower()
232         
233         if segtype == 'z': return
234         elif segtype == 'h':
235             path[i][1][0] = transform_point(transform, [path[i][1][0], 0])[0]
236         elif segtype == 'v':
237             path[i][1][0] = transform_point(transform, [0, path[i][1][0]])[1]
238         else:
239             first_coordinate = 0
240             if (segtype == 'a'): first_coordinate = 5           # for elliptical arcs, skip the radius x/y, rotation, large-arc, and sweep
241             for j in range(first_coordinate, len(path[i][1]), 2):
242                 x, y = path[i][1][j], path[i][1][j+1]
243                 x, y = transform_point(transform, (x, y))
244                 path[i][1][j] = x
245                 path[i][1][j+1] = y
246         
247     
248     def pathxy(self, path, i, setval=None):
249         """ Return the endpoint of the given path segment.
250             Inspects the segment type to know which elements are the endpoints.
251         """
252         segtype = path[i][0].lower()
253         x = y = 0
255         if segtype == 'z': i = 0
257         if segtype == 'h':
258             if setval: path[i][1][0] = setval[0]
259             else: x = path[i][1][0]
260             
261         elif segtype == 'v':
262             if setval: path[i][1][0] = setval[1]
263             else: y = path[i][1][0]
264         else:
265             if setval and segtype != 'z':
266                 path[i][1][-2] = setval[0]
267                 path[i][1][-1] = setval[1]
268             else:
269                 x = path[i][1][-2]
270                 y = path[i][1][-1]
272         if setval is None: return [x, y]
273     
274     def path_bounding_box(self, elem, parent_transform=None):
275         """ Returns [min_x, min_y], [max_x, max_y] of the transformed
276             element. (It doesn't make any sense to return the untransformed
277             bounding box, with the intent of transforming it later, because
278             the min/max points will be completely different points)
279             
280             The returned bounding box includes stroke-width offset.
281             
282             This function uses a simplistic algorithm & doesn't take curves
283             or arcs into account, just node positions.
284         """
285         # If we have a Live Path Effect, modify original-d. If anyone clamours
286         # for it, we could make an option to ignore paths with Live Path Effects
287         original_d = '{%s}original-d' % inkex.NSS['inkscape']
288         path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d']))
290         transform = self.transform(elem, parent_transform=parent_transform)
291         offset = self.elem_offset(elem, parent_transform)
292         
293         min_x = min_y = max_x = max_y = 0
294         for i in range(len(path)):
295             x, y = self.pathxy(path, i)
296             x, y = transform_point(transform, (x, y))
297             
298             if i == 0:
299                 min_x = max_x = x
300                 min_y = max_y = y
301             else:
302                 min_x = min(x, min_x)
303                 min_y = min(y, min_y)
304                 max_x = max(x, max_x)
305                 max_y = max(y, max_y)
306         
307         return (min_x-offset, min_y-offset), (max_x+offset, max_y+offset)
308             
309     
310     def snap_path_scale(self, elem, parent_transform=None):
311         # If we have a Live Path Effect, modify original-d. If anyone clamours
312         # for it, we could make an option to ignore paths with Live Path Effects
313         original_d = '{%s}original-d' % inkex.NSS['inkscape']
314         path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d']))
315         transform = self.transform(elem, parent_transform=parent_transform)
316         min_xy, max_xy = self.path_bounding_box(elem, parent_transform)
317         
318         width = max_xy[0] - min_xy[0]
319         height = max_xy[1] - min_xy[1]
321         # In case somebody tries to snap a 0-high element,
322         # or a curve/arc with all nodes in a line, and of course
323         # because we should always check for divide-by-zero!
324         if (width==0 or height==0): return
326         rescale = round(width)/width, round(height)/height
328         min_xy = transform_point(transform, min_xy, inverse=True)
329         max_xy = transform_point(transform, max_xy, inverse=True)
331         for i in range(len(path)):
332             self.transform_path_node([[1, 0, -min_xy[0]], [0, 1, -min_xy[1]]], path, i)     # center transform
333             self.transform_path_node([[rescale[0], 0, 0],
334                                        [0, rescale[1], 0]],
335                                        path, i)
336             self.transform_path_node([[1, 0, +min_xy[0]], [0, 1, +min_xy[1]]], path, i)     # uncenter transform
337         
338         path = simplepath.formatPath(path)
339         if original_d in elem.attrib: elem.attrib[original_d] = path
340         else: elem.attrib['d'] = path
342     def snap_path_pos(self, elem, parent_transform=None):
343         # If we have a Live Path Effect, modify original-d. If anyone clamours
344         # for it, we could make an option to ignore paths with Live Path Effects
345         original_d = '{%s}original-d' % inkex.NSS['inkscape']
346         path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d']))
347         transform = self.transform(elem, parent_transform=parent_transform)
348         min_xy, max_xy = self.path_bounding_box(elem, parent_transform)
350         fractional_offset = min_xy[0]-round(min_xy[0]), min_xy[1]-round(min_xy[1])-self.document_offset
351         fractional_offset = transform_dimensions(transform, fractional_offset[0], fractional_offset[1], inverse=True)
353         for i in range(len(path)):
354             self.transform_path_node([[1, 0, -fractional_offset[0]],
355                                        [0, 1, -fractional_offset[1]]],
356                                        path, i)
358         path = simplepath.formatPath(path)
359         if original_d in elem.attrib: elem.attrib[original_d] = path
360         else: elem.attrib['d'] = path
362     def snap_path(self, elem, parent_transform=None):
363         # If we have a Live Path Effect, modify original-d. If anyone clamours
364         # for it, we could make an option to ignore paths with Live Path Effects
365         original_d = '{%s}original-d' % inkex.NSS['inkscape']
366         path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d']))
368         transform = self.transform(elem, parent_transform=parent_transform)
370         if transform[0][1] or transform[1][0]:          # if we've got any skew/rotation, get outta here
371             raise TransformError("Selection contains transformations with skew/rotation")
372         
373         offset = self.elem_offset(elem, parent_transform) % 1
374         
375         prev_xy = self.pathxy(path, -1)
376         first_xy = self.pathxy(path, 0)
377         for i in range(len(path)):
378             segtype = path[i][0].lower()
379             xy = self.pathxy(path, i)
380             if segtype == 'z':
381                 xy = first_xy
382             if (i == len(path)-1) or \
383                ((i == len(path)-2) and path[-1][0].lower() == 'z'):
384                 next_xy = first_xy
385             else:
386                 next_xy = self.pathxy(path, i+1)
387             
388             if not (xy and prev_xy and next_xy):
389                 prev_xy = xy
390                 continue
391             
392             xy_untransformed = tuple(xy)
393             xy = list(transform_point(transform, xy))
394             prev_xy = transform_point(transform, prev_xy)
395             next_xy = transform_point(transform, next_xy)
396             
397             on_vertical = on_horizontal = False
398             
399             if horizontal(xy, prev_xy):
400                 if len(path) > 2 or i==0:                   # on 2-point paths, first.next==first.prev==last and last.next==last.prev==first
401                     xy[1] = prev_xy[1]                      # make the almost-equal values equal, so they round in the same direction
402                 on_horizontal = True
403             if horizontal(xy, next_xy):
404                 on_horizontal = True
405             
406             if vertical(xy, prev_xy):                       # as above
407                 if len(path) > 2 or i==0:
408                     xy[0] = prev_xy[0]
409                 on_vertical = True
410             if vertical(xy, next_xy):
411                 on_vertical = True
413             prev_xy = tuple(xy_untransformed)
414             
415             fractional_offset = [0,0]
416             if on_vertical:
417                 fractional_offset[0] = xy[0] - (round(xy[0]-offset) + offset)
418             if on_horizontal:
419                 fractional_offset[1] = xy[1] - (round(xy[1]-offset) + offset) - self.document_offset
420             
421             fractional_offset = transform_dimensions(transform, fractional_offset[0], fractional_offset[1], inverse=True)
422             self.transform_path_node([[1, 0, -fractional_offset[0]],
423                                        [0, 1, -fractional_offset[1]]],
424                                        path, i)
427         path = simplepath.formatPath(path)
428         if original_d in elem.attrib: elem.attrib[original_d] = path
429         else: elem.attrib['d'] = path
431     def snap_rect(self, elem, parent_transform=None):
432         transform = self.transform(elem, parent_transform=parent_transform)
434         if transform[0][1] or transform[1][0]:          # if we've got any skew/rotation, get outta here
435             raise TransformError("Selection contains transformations with skew/rotation")
436         
437         offset = self.elem_offset(elem, parent_transform) % 1
439         width = unittouu(elem.attrib['width'])
440         height = unittouu(elem.attrib['height'])
441         x = unittouu(elem.attrib['x'])
442         y = unittouu(elem.attrib['y'])
444         width, height = transform_dimensions(transform, width, height)
445         x, y = transform_point(transform, [x, y])
447         # Snap to the nearest pixel
448         height = round(height)
449         width = round(width)
450         x = round(x - offset) + offset                  # If there's a stroke of non-even width, it's shifted by half a pixel
451         y = round(y - offset) + offset
452         
453         width, height = transform_dimensions(transform, width, height, inverse=True)
454         x, y = transform_point(transform, [x, y], inverse=True)
455         
456         y += self.document_offset/transform[1][1]
457         
458         # Position the elem at the newly calculate values
459         elem.attrib['width'] = str(width)
460         elem.attrib['height'] = str(height)
461         elem.attrib['x'] = str(x)
462         elem.attrib['y'] = str(y)
463     
464     def snap_image(self, elem, parent_transform=None):
465         self.snap_rect(elem, parent_transform)
466     
467     def pixel_snap(self, elem, parent_transform=None):
468         if elemtype(elem, 'g'):
469             self.snap_transform(elem)
470             transform = self.transform(elem, parent_transform=parent_transform)
471             for e in elem:
472                 try:
473                     self.pixel_snap(e, transform)
474                 except TransformError, e:
475                     print >>sys.stderr, e
476             return
478         if not elemtype(elem, ('path', 'rect', 'image')):
479             return
481         self.snap_transform(elem)
482         try:
483             self.snap_stroke(elem, parent_transform)
484         except TransformError, e:
485             print >>sys.stderr, e
487         if elemtype(elem, 'path'):
488             self.snap_path_scale(elem, parent_transform)
489             self.snap_path_pos(elem, parent_transform)
490             self.snap_path(elem, parent_transform)                      # would be quite useful to make this an option, as scale/pos alone doesn't mess with the path itself, and works well for sans-serif text
491         elif elemtype(elem, 'rect'): self.snap_rect(elem, parent_transform)
492         elif elemtype(elem, 'image'): self.snap_image(elem, parent_transform)
494     def effect(self):
495         svg = self.document.getroot()
496         
497         self.document_offset = unittouu(svg.attrib['height']) % 1       # although SVG units are absolute, the elements are positioned relative to the top of the page, rather than zero
499         for id, elem in self.selected.iteritems():
500             try:
501                 self.pixel_snap(elem)
502             except TransformError, e:
503                 print >>sys.stderr, e
506 if __name__ == '__main__':
507     effect = PixelSnapEffect()
508     effect.affect()