From 796977f97e351f01d3e904ec89b99f0c2eb33d11 Mon Sep 17 00:00:00 2001 From: JazzyNico Date: Fri, 5 Feb 2010 08:57:16 +0100 Subject: [PATCH] Extensions. New pixelsnap extension. --- share/extensions/Makefile.am | 2 + share/extensions/pixelsnap.inx | 17 ++ share/extensions/pixelsnap.py | 509 +++++++++++++++++++++++++++++++++ 3 files changed, 528 insertions(+) create mode 100644 share/extensions/pixelsnap.inx create mode 100644 share/extensions/pixelsnap.py diff --git a/share/extensions/Makefile.am b/share/extensions/Makefile.am index 33dfcc25c..110522895 100644 --- a/share/extensions/Makefile.am +++ b/share/extensions/Makefile.am @@ -88,6 +88,7 @@ extensions = \ pathmodifier.py\ perfectboundcover.py \ perspective.py \ + pixelsnap.py \ plt_output.py \ polyhedron_3d.py \ printing-marks.py \ @@ -220,6 +221,7 @@ modules = \ pathscatter.inx\ perfectboundcover.inx \ perspective.inx \ + pixelsnap.inx \ plt_input.inx \ plt_output.inx \ polyhedron_3d.inx \ diff --git a/share/extensions/pixelsnap.inx b/share/extensions/pixelsnap.inx new file mode 100644 index 000000000..5413148dc --- /dev/null +++ b/share/extensions/pixelsnap.inx @@ -0,0 +1,17 @@ + + <_name>PixelSnap + bryhoyt.pixelsnap + pixelsnap.py + Snap all paths in selection to pixels. Snaps borders to half-points and fills to full points + + + + + + + + + + diff --git a/share/extensions/pixelsnap.py b/share/extensions/pixelsnap.py new file mode 100644 index 000000000..95ae7f0dc --- /dev/null +++ b/share/extensions/pixelsnap.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python + +""" +TODO: This only snaps selected elements, and if those elements are part of a + group or layer that has it's own transform, that won't be taken into + account, unless you snap the group or layer as a whole. This can account + for unexpected results in some cases (eg where you've got a non-integer + translation on the layer you're working in, the elements in that layer + won't snap properly). The workaround for now is to snap the whole + group/layer, or remove the transform on the group/layer. + + I could fix it in the code by traversing the parent elements up to the + document root & calculating the cumulative parent_transform. This could + be done at the top of the pixel_snap method if parent_transform==None, + or before calling it for the first time. + +TODO: Transforming points isn't quite perfect, to say the least. In particular, + when translating a point bezier curve, we translate the handles by the same amount. + BUT, some handles that are attached to a particular point are conceptually + handles of the prev/next node. + Best way to fix it would be to keep a list of the fractional_offsets[] of + each point, without transforming anything. Then go thru each point and + transform the appropriate handle according to the relevant fraction_offset + in the list. + + i.e. calculate first, then modify. + + In fact, that might be a simpler algorithm anyway -- it avoids having + to keep track of all the first_xy/next_xy guff. + +TODO: make elem_offset return [x_offset, y_offset] so we can handle non-symetric scaling + +------------ + +Note: This doesn't work very well on paths which have both straight segments + and curved segments. + The biggest three problems are: + a) we don't take handles into account (segments where the nodes are + aligned are always treated as straight segments, even where the + handles make it curve) + b) when we snap a straight segment right before/after a curve, it + doesn't make any attempt to keep the transition from the straight + segment to the curve smooth. + c) no attempt is made to keep equal widths equal. (or nearly-equal + widths nearly-equal). For example, font strokes. + + I guess that amounts to the problyem that font hinting solves for fonts. + I wonder if I could find an automatic font-hinting algorithm and munge + it to my purposes? + + Some good autohinting concepts that may help: + http://freetype.sourceforge.net/autohinting/archive/10Mar2000/hinter.html + +Note: Paths that have curves & arcs on some sides of the bounding box won't + be snapped correctly on that side of the bounding box, and nor will they + be translated/resized correctly before the path is modified. Doesn't affect + most applications of this extension, but it highlights the fact that we + take a geometrically simplistic approach to inspecting & modifying the path. +""" + +from __future__ import division + +import sys +# *** numpy causes issue #4 on Mac OS 10.6.2. I use it for +# matrix inverse -- my linear algebra's a bit rusty, but I could implement my +# own matrix inverse function if necessary, I guess. +from numpy import matrix +import simplestyle, simpletransform, simplepath + +# INKEX MODULE +# If you get the "No module named inkex" error, uncomment the relevant line +# below by removing the '#' at the start of the line. +# +#sys.path += ['/usr/share/inkscape/extensions'] # If you're using a standard Linux installation +#sys.path += ['/usr/local/share/inkscape/extensions'] # If you're using a custom Linux installation +#sys.path += ['C:\\Program Files\\Inkscape\\share\\extensions'] # If you're using a standard Windows installation + +try: + import inkex + from inkex import unittouu +except ImportError: + raise ImportError("No module named inkex.\nPlease edit the file %s and see the section titled 'INKEX MODULE'" % __file__) + +Precision = 5 # number of digits of precision for comparing float numbers + +MaxGradient = 1/200 # lines that are almost-but-not-quite straight will be snapped, too. + +class TransformError(Exception): pass + +def elemtype(elem, matches): + if not isinstance(matches, (list, tuple)): matches = [matches] + for m in matches: + if elem.tag == inkex.addNS(m, 'svg'): return True + return False + +def invert_transform(transform): + transform = transform[:] # duplicate list to avoid modifying it + transform += [[0, 0, 1]] + inverse = matrix(transform).I.tolist() + inverse.pop() + return inverse + +def transform_point(transform, pt, inverse=False): + """ Better than simpletransform.applyTransformToPoint, + a) coz it's a simpler name + b) coz it returns the new xy, rather than modifying the input + """ + if inverse: + transform = invert_transform(transform) + + x = transform[0][0]*pt[0] + transform[0][1]*pt[1] + transform[0][2] + y = transform[1][0]*pt[0] + transform[1][1]*pt[1] + transform[1][2] + return x,y + +def transform_dimensions(transform, width=None, height=None, inverse=False): + """ Dimensions don't get translated. I'm not sure how much diff rotate/skew + makes in this context, but we currently ignore anything besides scale. + """ + if inverse: transform = invert_transform(transform) + + if width is not None: width *= transform[0][0] + if height is not None: height *= transform[1][1] + + if width is not None and height is not None: return width, height + if width is not None: return width + if height is not None: return height + + +def vertical(pt1, pt2): + hlen = abs(pt1[0] - pt2[0]) + vlen = abs(pt1[1] - pt2[1]) + if vlen==0 and hlen==0: + return True + elif vlen==0: + return False + return (hlen / vlen) < MaxGradient + +def horizontal(pt1, pt2): + hlen = round(abs(pt1[0] - pt2[0]), Precision) + vlen = round(abs(pt1[1] - pt2[1]), Precision) + if hlen==0 and vlen==0: + return True + elif hlen==0: + return False + return (vlen / hlen) < MaxGradient + +class PixelSnapEffect(inkex.Effect): + def elem_offset(self, elem, parent_transform=None): + """ Returns a value which is the amount the + bounding-box is offset due to the stroke-width. + Transform is taken into account. + """ + stroke_width = self.stroke_width(elem) + if stroke_width == 0: return 0 # if there's no stroke, no need to worry about the transform + + transform = self.transform(elem, parent_transform=parent_transform) + if abs(abs(transform[0][0]) - abs(transform[1][1])) > (10**-Precision): + 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 + + stroke_width = transform_dimensions(transform, width=stroke_width) + + return (stroke_width/2) + + def stroke_width(self, elem, setval=None): + """ Return stroke-width in pixels, untransformed + """ + style = simplestyle.parseStyle(elem.attrib.get('style', '')) + stroke = style.get('stroke', None) + if stroke == 'none': stroke = None + + stroke_width = 0 + if stroke and setval is None: + stroke_width = unittouu(style.get('stroke-width', '').strip()) + + if setval: + style['stroke-width'] = str(setval) + elem.attrib['style'] = simplestyle.formatStyle(style) + else: + return stroke_width + + def snap_stroke(self, elem, parent_transform=None): + transform = self.transform(elem, parent_transform=parent_transform) + + stroke_width = self.stroke_width(elem) + if (stroke_width == 0): return # no point raising a TransformError if there's no stroke to snap + + if abs(abs(transform[0][0]) - abs(transform[1][1])) > (10**-Precision): + raise TransformError("Selection contains non-symetric scaling, can't snap stroke width") + + if stroke_width: + stroke_width = transform_dimensions(transform, width=stroke_width) + stroke_width = round(stroke_width) + stroke_width = transform_dimensions(transform, width=stroke_width, inverse=True) + self.stroke_width(elem, stroke_width) + + def transform(self, elem, setval=None, parent_transform=None): + """ Gets this element's transform. Use setval=matrix to + set this element's transform. + You can only specify parent_transform when getting. + """ + transform = elem.attrib.get('transform', '').strip() + + if transform: + transform = simpletransform.parseTransform(transform) + else: + transform = [[1,0,0], [0,1,0], [0,0,1]] + if parent_transform: + transform = simpletransform.composeTransform(parent_transform, transform) + + if setval: + elem.attrib['transform'] = simpletransform.formatTransform(setval) + else: + return transform + + def snap_transform(self, elem): + # Only snaps the x/y translation of the transform, nothing else. + # Scale transforms are handled only in snap_rect() + # Doesn't take any parent_transform into account -- assumes + # that the parent's transform has already been snapped. + transform = self.transform(elem) + if transform[0][1] or transform[1][0]: return # if we've got any skew/rotation, get outta here + + transform[0][2] = round(transform[0][2]) + transform[1][2] = round(transform[1][2]) + + self.transform(elem, transform) + + def transform_path_node(self, transform, path, i): + """ Modifies a segment so that every point is transformed, including handles + """ + segtype = path[i][0].lower() + + if segtype == 'z': return + elif segtype == 'h': + path[i][1][0] = transform_point(transform, [path[i][1][0], 0])[0] + elif segtype == 'v': + path[i][1][0] = transform_point(transform, [0, path[i][1][0]])[1] + else: + first_coordinate = 0 + if (segtype == 'a'): first_coordinate = 5 # for elliptical arcs, skip the radius x/y, rotation, large-arc, and sweep + for j in range(first_coordinate, len(path[i][1]), 2): + x, y = path[i][1][j], path[i][1][j+1] + x, y = transform_point(transform, (x, y)) + path[i][1][j] = x + path[i][1][j+1] = y + + + def pathxy(self, path, i, setval=None): + """ Return the endpoint of the given path segment. + Inspects the segment type to know which elements are the endpoints. + """ + segtype = path[i][0].lower() + x = y = 0 + + if segtype == 'z': i = 0 + + if segtype == 'h': + if setval: path[i][1][0] = setval[0] + else: x = path[i][1][0] + + elif segtype == 'v': + if setval: path[i][1][0] = setval[1] + else: y = path[i][1][0] + else: + if setval and segtype != 'z': + path[i][1][-2] = setval[0] + path[i][1][-1] = setval[1] + else: + x = path[i][1][-2] + y = path[i][1][-1] + + if setval is None: return [x, y] + + def path_bounding_box(self, elem, parent_transform=None): + """ Returns [min_x, min_y], [max_x, max_y] of the transformed + element. (It doesn't make any sense to return the untransformed + bounding box, with the intent of transforming it later, because + the min/max points will be completely different points) + + The returned bounding box includes stroke-width offset. + + This function uses a simplistic algorithm & doesn't take curves + or arcs into account, just node positions. + """ + # If we have a Live Path Effect, modify original-d. If anyone clamours + # for it, we could make an option to ignore paths with Live Path Effects + original_d = '{%s}original-d' % inkex.NSS['inkscape'] + path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d'])) + + transform = self.transform(elem, parent_transform=parent_transform) + offset = self.elem_offset(elem, parent_transform) + + min_x = min_y = max_x = max_y = 0 + for i in range(len(path)): + x, y = self.pathxy(path, i) + x, y = transform_point(transform, (x, y)) + + if i == 0: + min_x = max_x = x + min_y = max_y = y + else: + min_x = min(x, min_x) + min_y = min(y, min_y) + max_x = max(x, max_x) + max_y = max(y, max_y) + + return (min_x-offset, min_y-offset), (max_x+offset, max_y+offset) + + + def snap_path_scale(self, elem, parent_transform=None): + # If we have a Live Path Effect, modify original-d. If anyone clamours + # for it, we could make an option to ignore paths with Live Path Effects + original_d = '{%s}original-d' % inkex.NSS['inkscape'] + path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d'])) + transform = self.transform(elem, parent_transform=parent_transform) + min_xy, max_xy = self.path_bounding_box(elem, parent_transform) + + width = max_xy[0] - min_xy[0] + height = max_xy[1] - min_xy[1] + + # In case somebody tries to snap a 0-high element, + # or a curve/arc with all nodes in a line, and of course + # because we should always check for divide-by-zero! + if (width==0 or height==0): return + + rescale = round(width)/width, round(height)/height + + min_xy = transform_point(transform, min_xy, inverse=True) + max_xy = transform_point(transform, max_xy, inverse=True) + + for i in range(len(path)): + self.transform_path_node([[1, 0, -min_xy[0]], [0, 1, -min_xy[1]]], path, i) # center transform + self.transform_path_node([[rescale[0], 0, 0], + [0, rescale[1], 0]], + path, i) + self.transform_path_node([[1, 0, +min_xy[0]], [0, 1, +min_xy[1]]], path, i) # uncenter transform + + path = simplepath.formatPath(path) + if original_d in elem.attrib: elem.attrib[original_d] = path + else: elem.attrib['d'] = path + + def snap_path_pos(self, elem, parent_transform=None): + # If we have a Live Path Effect, modify original-d. If anyone clamours + # for it, we could make an option to ignore paths with Live Path Effects + original_d = '{%s}original-d' % inkex.NSS['inkscape'] + path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d'])) + transform = self.transform(elem, parent_transform=parent_transform) + min_xy, max_xy = self.path_bounding_box(elem, parent_transform) + + fractional_offset = min_xy[0]-round(min_xy[0]), min_xy[1]-round(min_xy[1])-self.document_offset + fractional_offset = transform_dimensions(transform, fractional_offset[0], fractional_offset[1], inverse=True) + + for i in range(len(path)): + self.transform_path_node([[1, 0, -fractional_offset[0]], + [0, 1, -fractional_offset[1]]], + path, i) + + path = simplepath.formatPath(path) + if original_d in elem.attrib: elem.attrib[original_d] = path + else: elem.attrib['d'] = path + + def snap_path(self, elem, parent_transform=None): + # If we have a Live Path Effect, modify original-d. If anyone clamours + # for it, we could make an option to ignore paths with Live Path Effects + original_d = '{%s}original-d' % inkex.NSS['inkscape'] + path = simplepath.parsePath(elem.attrib.get(original_d, elem.attrib['d'])) + + transform = self.transform(elem, parent_transform=parent_transform) + + if transform[0][1] or transform[1][0]: # if we've got any skew/rotation, get outta here + raise TransformError("Selection contains transformations with skew/rotation") + + offset = self.elem_offset(elem, parent_transform) % 1 + + prev_xy = self.pathxy(path, -1) + first_xy = self.pathxy(path, 0) + for i in range(len(path)): + segtype = path[i][0].lower() + xy = self.pathxy(path, i) + if segtype == 'z': + xy = first_xy + if (i == len(path)-1) or \ + ((i == len(path)-2) and path[-1][0].lower() == 'z'): + next_xy = first_xy + else: + next_xy = self.pathxy(path, i+1) + + if not (xy and prev_xy and next_xy): + prev_xy = xy + continue + + xy_untransformed = tuple(xy) + xy = list(transform_point(transform, xy)) + prev_xy = transform_point(transform, prev_xy) + next_xy = transform_point(transform, next_xy) + + on_vertical = on_horizontal = False + + if horizontal(xy, prev_xy): + if len(path) > 2 or i==0: # on 2-point paths, first.next==first.prev==last and last.next==last.prev==first + xy[1] = prev_xy[1] # make the almost-equal values equal, so they round in the same direction + on_horizontal = True + if horizontal(xy, next_xy): + on_horizontal = True + + if vertical(xy, prev_xy): # as above + if len(path) > 2 or i==0: + xy[0] = prev_xy[0] + on_vertical = True + if vertical(xy, next_xy): + on_vertical = True + + prev_xy = tuple(xy_untransformed) + + fractional_offset = [0,0] + if on_vertical: + fractional_offset[0] = xy[0] - (round(xy[0]-offset) + offset) + if on_horizontal: + fractional_offset[1] = xy[1] - (round(xy[1]-offset) + offset) - self.document_offset + + fractional_offset = transform_dimensions(transform, fractional_offset[0], fractional_offset[1], inverse=True) + self.transform_path_node([[1, 0, -fractional_offset[0]], + [0, 1, -fractional_offset[1]]], + path, i) + + + path = simplepath.formatPath(path) + if original_d in elem.attrib: elem.attrib[original_d] = path + else: elem.attrib['d'] = path + + def snap_rect(self, elem, parent_transform=None): + transform = self.transform(elem, parent_transform=parent_transform) + + if transform[0][1] or transform[1][0]: # if we've got any skew/rotation, get outta here + raise TransformError("Selection contains transformations with skew/rotation") + + offset = self.elem_offset(elem, parent_transform) % 1 + + width = unittouu(elem.attrib['width']) + height = unittouu(elem.attrib['height']) + x = unittouu(elem.attrib['x']) + y = unittouu(elem.attrib['y']) + + width, height = transform_dimensions(transform, width, height) + x, y = transform_point(transform, [x, y]) + + # Snap to the nearest pixel + height = round(height) + width = round(width) + x = round(x - offset) + offset # If there's a stroke of non-even width, it's shifted by half a pixel + y = round(y - offset) + offset + + width, height = transform_dimensions(transform, width, height, inverse=True) + x, y = transform_point(transform, [x, y], inverse=True) + + y += self.document_offset/transform[1][1] + + # Position the elem at the newly calculate values + elem.attrib['width'] = str(width) + elem.attrib['height'] = str(height) + elem.attrib['x'] = str(x) + elem.attrib['y'] = str(y) + + def snap_image(self, elem, parent_transform=None): + self.snap_rect(elem, parent_transform) + + def pixel_snap(self, elem, parent_transform=None): + if elemtype(elem, 'g'): + self.snap_transform(elem) + transform = self.transform(elem, parent_transform=parent_transform) + for e in elem: + try: + self.pixel_snap(e, transform) + except TransformError, e: + print >>sys.stderr, e + return + + if not elemtype(elem, ('path', 'rect', 'image')): + return + + self.snap_transform(elem) + try: + self.snap_stroke(elem, parent_transform) + except TransformError, e: + print >>sys.stderr, e + + if elemtype(elem, 'path'): + self.snap_path_scale(elem, parent_transform) + self.snap_path_pos(elem, parent_transform) + 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 + elif elemtype(elem, 'rect'): self.snap_rect(elem, parent_transform) + elif elemtype(elem, 'image'): self.snap_image(elem, parent_transform) + + def effect(self): + svg = self.document.getroot() + + 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 + + for id, elem in self.selected.iteritems(): + try: + self.pixel_snap(elem) + except TransformError, e: + print >>sys.stderr, e + + +if __name__ == '__main__': + effect = PixelSnapEffect() + effect.affect() + -- 2.30.2