diff options
Diffstat (limited to '')
-rwxr-xr-x | share/extensions/pixelsnap.py | 501 |
1 files changed, 501 insertions, 0 deletions
diff --git a/share/extensions/pixelsnap.py b/share/extensions/pixelsnap.py new file mode 100755 index 0000000..e414af6 --- /dev/null +++ b/share/extensions/pixelsnap.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python +# coding=utf-8 +# +# Copyright (c) 2009 Bryan Hoyt (MIT License) +# 2011 Nicolas Dufour <nicoduf@yahoo.fr> +# 2013 Johan B. C. Engelen <j.b.c.engelen@alumnus.utwente.nl> +# 2019 Martin Owens <doctormo@gmail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +""" +TODO: mark elements that have previously been snapped, along with the settings + used, so that the same settings can be used for that element next time when + it's selected as part of a group (and add an option to the extension dialog + "Use previous/default settings" which is selected by default) + +TODO: make elem_offset return [x_offset, y_offset] so we can handle non-symetric scaling + => will probably need to take into account non-symetric scaling on stroke-widths, + too (horizontal vs vertical strokes) + +TODO: Transforming points isn't quite perfect, to say the least. In particular, + when translating a point on a 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 through 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. + +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. + +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 +from __future__ import print_function + +import sys + +import inkex +from inkex import PathElement, Group, Image, Rectangle, ShapeElement, Transform +from inkex.localization import inkex_gettext as _ + +Precision = 5 # number of digits of precision for comparing float numbers + + +class TransformError(Exception): + pass + + +def transform_point(transform, pt, inverse=False): + """apply_to_point with inbuilt inverse""" + if inverse: + transform = -transform + return transform.apply_to_point(pt) + + +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 = -transform + + if width is not None: + width *= transform.a + if height is not None: + height *= transform.d + + 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 + + +class PixelSnap(inkex.EffectExtension): + """Snap objects to pixels""" + + def add_arguments(self, pars): + """Add inx options""" + pars.add_argument( + "-a", + "--snap_ancestors", + type=inkex.Boolean, + default=True, + help="Snap unselected ancestors' translations " + "(groups, layers, document height) first", + ) + pars.add_argument( + "-t", + "--ancestor_offset", + type=inkex.Boolean, + default=True, + help="Calculate offset relative to unselected ancestors' " + "transforms (includes document height offset)", + ) + pars.add_argument( + "-g", + "--max_gradient", + type=float, + default=0.5, + help="Maximum slope to consider straight (%)", + ) + pars.add_argument( + "-s", + "--snap_to", + default="tl", + choices=["tl", "bl"], + help="Origin of the coordinate system", + ) + + def vertical(self, 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) < self.options.max_gradient / 100 + + def horizontal(self, 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) < self.options.max_gradient / 100 + + def stroke_width_offset(self, elem, parent_transform=None): + """Returns 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 = elem.transform @ Transform(parent_transform) + + if abs(abs(transform.a) - abs(transform.d)) > (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 1 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): + """Get/set stroke-width in pixels, untransformed""" + style = elem.style + stroke = style("stroke") + + stroke_width = 0 + if stroke and setval is None: + stroke_width = self.svg.to_dimensionless(style("stroke-width").strip()) + + if setval: + style["stroke-width"] = setval + else: + return stroke_width + + 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 snap_path_scale(self, elem, parent_transform=None): + + path = elem.original_path.to_arrays() + transform = elem.transform @ Transform(parent_transform) + bbox = elem.bounding_box() + + # 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 not bbox.width or not bbox.height: + return + + width, height = bbox.width, bbox.height + min_xy, max_xy = bbox.minimum, bbox.maximum + 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)): + translate = Transform(translate=min_xy) + self.transform_path_node(-translate, path, i) # center transform + self.transform_path_node(Transform(scale=rescale), path, i) + self.transform_path_node(translate, path, i) # uncenter transform + + elem.original_path = path + + def snap_path_pos(self, elem, parent_transform=None): + path = elem.original_path.to_arrays() + transform = elem.transform @ Transform(parent_transform) + bbox = elem.bounding_box() + min_xy, max_xy = bbox.minimum, bbox.maximum + + 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(-Transform(translate=fractional_offset), path, i) + + path = str(inkex.Path(path)) + if elem.get("inkscape:original-d"): + elem.set("inkscape:original-d", path) + else: + elem.set("d", path) + + def snap_transform(self, elem): + # Only snaps the x/y translation of the transform, nothing else. + # Doesn't take any parent_transform into account -- assumes + # that the parent's transform has already been snapped. + transform = elem.transform + # if we've got any skew/rotation, get outta here + if transform.c or transform.b: + raise TransformError( + _("TR: Selection contains transformations with skew/rotation") + ) + + trm = list(transform.to_hexad()) + trm[4] = round(transform.e) + trm[5] = round(transform.f) + elem.transform @= Transform(trm) + + def snap_stroke(self, elem, parent_transform=None): + transform = elem.transform @ 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.a) - abs(transform.d)) > (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 snap_path(self, elem, parent_transform=None): + path = elem.original_path.to_arrays() + + transform = elem.transform @ Transform(parent_transform) + + if transform.c or transform.b: # if we've got any skew/rotation, get outta here + raise TransformError( + _("Path: Selection contains transformations with skew/rotation") + ) + + offset = self.stroke_width_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 self.horizontal(xy, prev_xy): + # on 2-point paths, first.next==first.prev==last and last.next==last.prev==first + if len(path) > 2 or i == 0: + # make the almost-equal values equal, so they round in the same direction + xy[1] = prev_xy[1] + on_horizontal = True + if self.horizontal(xy, next_xy): + on_horizontal = True + + if self.vertical(xy, prev_xy): # as above + if len(path) > 2 or i == 0: + xy[0] = prev_xy[0] + on_vertical = True + if self.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(-Transform(translate=fractional_offset), path, i) + + elem.original_path = path + + def snap_rect(self, elem, parent_transform=None): + transform = elem.transform @ Transform(parent_transform) + + if transform.c or transform.b: # if we've got any skew/rotation, get outta here + raise TransformError( + _("Rect: Selection contains transformations with skew/rotation") + ) + + offset = self.stroke_width_offset(elem, parent_transform) % 1 + + width = self.svg.to_dimensionless(elem.attrib["width"]) + height = self.svg.to_dimensionless(elem.attrib["height"]) + x = self.svg.to_dimensionless(elem.attrib["x"]) + y = self.svg.to_dimensionless(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.d + + # 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 not isinstance(elem, (Group, Image, Rectangle, PathElement)): + return + + if isinstance(elem, Group): + self.snap_transform(elem) + transform = elem.transform * Transform(parent_transform) + for child in elem: + try: + self.pixel_snap(child, transform) + except TransformError as err: + raise inkex.AbortExtension(str(err)) + return + + # If we've been given a parent_transform, we can assume that the + # parents have already been snapped, or don't need to be + if self.options.snap_ancestors and parent_transform is None: + # Loop through ancestors from outermost to innermost, excluding this element. + for child in elem.ancestors(): + self.snap_transform(child) + + # If we haven't been given a parent_transform, then we need to calculate it + if self.options.ancestor_offset and parent_transform is None: + if isinstance(elem.getparent(), ShapeElement): + parent_transform = elem.getparent().composed_transform() + + self.snap_transform(elem) + try: + self.snap_stroke(elem, parent_transform) + except TransformError as err: + raise inkex.AbortExtension(str(err)) + + if isinstance(elem, PathElement): + 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 isinstance(elem, Rectangle): + self.snap_rect(elem, parent_transform) + elif isinstance(elem, Image): + self.snap_image(elem, parent_transform) + + def effect(self): + if self.options.snap_to == "bl": + self.document_offset = ( + self.svg.to_dimensionless(self.svg.get_viewbox()[3]) % 1 + ) + else: + self.document_offset = 0 + for id, elem in self.svg.selection.items(): + try: + self.pixel_snap(elem) + except TransformError as err: + raise inkex.AbortExtension(str(err)) + + +if __name__ == "__main__": + PixelSnap().run() |