home *** CD-ROM | disk | FTP | other *** search
/ Chip 2011 November / CHIP_2011_11.iso / Programy / Narzedzia / Inkscape / Inkscape-0.48.2-1-win32.exe / share / extensions / pixelsnap.py < prev    next >
Text File  |  2011-07-08  |  22KB  |  510 lines

  1. #!/usr/bin/env python
  2.  
  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.
  16.  
  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.
  30.  
  31. TODO: make elem_offset return [x_offset, y_offset] so we can handle non-symetric scaling
  32.  
  33. ------------
  34.  
  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
  53.  
  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. """
  60.  
  61. from __future__ import division
  62.  
  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
  69.  
  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
  77.  
  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__)
  83.  
  84. Precision = 5                   # number of digits of precision for comparing float numbers
  85.  
  86. MaxGradient = 1/200             # lines that are almost-but-not-quite straight will be snapped, too.
  87.  
  88. class TransformError(Exception): pass
  89.  
  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
  95.  
  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
  102.  
  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
  114.  
  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)
  120.  
  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
  127.  
  128.  
  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
  137.  
  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
  146.  
  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
  155.  
  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
  159.  
  160.         stroke_width = transform_dimensions(transform, width=stroke_width)
  161.  
  162.         return (stroke_width/2)
  163.  
  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
  180.  
  181.     def snap_stroke(self, elem, parent_transform=None):
  182.         transform = self.transform(elem, parent_transform=parent_transform)
  183.  
  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
  186.  
  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)
  195.  
  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
  214.  
  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
  254.  
  255.         if segtype == 'z': i = 0
  256.  
  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]
  271.  
  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']))
  289.  
  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]
  320.  
  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
  325.  
  326.         rescale = round(width)/width, round(height)/height
  327.  
  328.         min_xy = transform_point(transform, min_xy, inverse=True)
  329.         max_xy = transform_point(transform, max_xy, inverse=True)
  330.  
  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
  341.  
  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)
  349.  
  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)
  352.  
  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)
  357.  
  358.         path = simplepath.formatPath(path)
  359.         if original_d in elem.attrib: elem.attrib[original_d] = path
  360.         else: elem.attrib['d'] = path
  361.  
  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']))
  367.  
  368.         transform = self.transform(elem, parent_transform=parent_transform)
  369.  
  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
  412.  
  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)
  425.  
  426.  
  427.         path = simplepath.formatPath(path)
  428.         if original_d in elem.attrib: elem.attrib[original_d] = path
  429.         else: elem.attrib['d'] = path
  430.  
  431.     def snap_rect(self, elem, parent_transform=None):
  432.         transform = self.transform(elem, parent_transform=parent_transform)
  433.  
  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
  438.  
  439.         width = unittouu(elem.attrib['width'])
  440.         height = unittouu(elem.attrib['height'])
  441.         x = unittouu(elem.attrib['x'])
  442.         y = unittouu(elem.attrib['y'])
  443.  
  444.         width, height = transform_dimensions(transform, width, height)
  445.         x, y = transform_point(transform, [x, y])
  446.  
  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
  477.  
  478.         if not elemtype(elem, ('path', 'rect', 'image')):
  479.             return
  480.  
  481.         self.snap_transform(elem)
  482.         try:
  483.             self.snap_stroke(elem, parent_transform)
  484.         except TransformError, e:
  485.             print >>sys.stderr, e
  486.  
  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)
  493.  
  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
  498.  
  499.         for id, elem in self.selected.iteritems():
  500.             try:
  501.                 self.pixel_snap(elem)
  502.             except TransformError, e:
  503.                 print >>sys.stderr, e
  504.  
  505.  
  506. if __name__ == '__main__':
  507.     effect = PixelSnapEffect()
  508.     effect.affect()
  509.  
  510.