#!/usr/bin/env python3 # # License: GPL2 # Copyright Mark "Klowner" Riedesel # https://github.com/Klowner/inkscape-applytransforms # import copy import math from lxml import etree import re import inkex from inkex.paths import CubicSuperPath, Path from inkex.transforms import Transform from inkex.styles import Style NULL_TRANSFORM = Transform([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) class ApplyTransformations(inkex.EffectExtension): def effect(self): if self.svg.selected: for id, shape in self.svg.selected.items(): self.recursiveFuseTransform(shape) else: self.recursiveFuseTransform(self.document.getroot()) @staticmethod def objectToPath(element): if element.tag == inkex.addNS('g', 'svg'): return element if element.tag == inkex.addNS('path', 'svg') or element.tag == 'path': for attName in element.attrib.keys(): if ("sodipodi" in attName) or ("inkscape" in attName): del element.attrib[attName] return element return element def scaleStyleAttrib(self, element, transf, attrib): if 'style' in element.attrib: style = element.attrib.get('style') style = dict(Style.parse_str(style)) update = False if attrib in style: try: style_attrib = self.svg.unittouu(style.get(attrib)) / self.svg.unittouu("1px") style_attrib *= math.sqrt(abs(transf.a * transf.d - transf.b * transf.c)) style[attrib] = str(style_attrib) update = True except AttributeError as e: pass if update: element.attrib['style'] = Style(style).to_str() if 'stroke-width' in element.attrib: style = element.attrib.get('style') style = dict(Style.parse_str(style)) update = False try: stroke_width = self.svg.unittouu(element.attrib.get('stroke-width')) / self.svg.unittouu("1px") stroke_width *= math.sqrt(abs(transf.a * transf.d - transf.b * transf.c)) element.attrib['stroke-width'] = str(stroke_width) update = True except AttributeError as e: pass def scaleMultiple(self, string, factor): array = string.strip().split(' ') for k, p in enumerate(array): if p != '0': array[k] = str(float(p) * factor) return ' '.join(array) def transformRectangle(self, element, transf: Transform): x = float(element.get('x', '0')) y = float(element.get('y', '0')) width = float(element.get('width', '0')) height = float(element.get('height', '0')) rx = float(element.get('rx', '0')) ry = float(element.get('ry', '0')) # Extract translation, scaling and rotation a, b, c, d = transf.a, transf.b, transf.c, transf.d tx, ty = transf.e, transf.f sx = math.sqrt(a**2 + b**2) sy = math.sqrt(c**2 + d**2) angle = math.degrees(math.atan2(b, a)) # Calculate the center of the rectangle cx = x + width / 2 cy = y + height / 2 # Apply the transformation to the center point new_cx, new_cy = transf.apply_to_point((cx, cy)) new_x = new_cx - (width * sx) / 2 new_y = new_cy - (height * sy) / 2 # Update rectangle attributes element.set('x', str(new_x)) element.set('y', str(new_y)) element.set('width', str(width * sx)) element.set('height', str(height * sy)) # Apply scale to rx and ry if they exist if rx > 0: element.set('rx', str(rx * sx)) if ry > 0: element.set('ry', str(ry * sy)) # Add rotation if it exists if abs(angle) > 1e-6: tr = Transform(f"rotate({angle:.6f},{new_cx:.6f},{new_cy:.6f})") element.attrib['transform'] = str(f"rotate({angle:.3f} {new_x:.3f} {new_y:.3f})") def transformText(self, element, transf: Transform): x = float(element.get('x', '0')) y = float(element.get('y', '0')) new_x, new_y = transf.apply_to_point((x, y)) element.set("x", str(new_x)) element.set("y", str(new_y)) # Extract translation, scaling and rotation a, b, c, d = transf.a, transf.b, transf.c, transf.d sx = math.sqrt(a**2 + b**2) sy = math.sqrt(c**2 + d**2) angle = math.degrees(math.atan2(b, a)) if 'dx' in element.attrib: element.set('dx', self.scaleMultiple(element.get('dx'), sx)) if 'dy' in element.attrib: element.set('dy', self.scaleMultiple(element.get('dy'), sy)) # Add rotation if it exists if abs(angle) > 1e-6: tr = str(f"rotate({angle:.3f} {new_x:.3f} {new_y:.3f})") element.set('transform',tr) def transformTspan(self, element, transf: Transform): x = float(element.get('x', '0')) y = float(element.get('y', '0')) # Extract translation, scaling, rotation and parent xy a, b, c, d = transf.a, transf.b, transf.c, transf.d sx = math.sqrt(a**2 + c**2) sy = math.sqrt(b**2 + d**2) angle = math.degrees(math.atan2(b, a)) parentx = element.getparent().get('x', '0') parenty = element.getparent().get('y', '0') if 'x' not in element.attrib or x == 0: element.set('x', parentx) else: element.set('x', str(float(parentx) + x * sx)) if 'y' not in element.attrib or y == 0: element.set('y', parenty) else: element.set('y', str(float(parenty) + y * sy)) if 'dx' in element.attrib: element.set('dx', self.scaleMultiple(element.get('dx'), sx)) if 'dy' in element.attrib: element.set('dy', self.scaleMultiple(element.get('dy'), sy)) def recursiveFuseTransform(self, element, transf=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): transf = Transform(transf) @ Transform(element.get("transform", None)) #a, b, c, d = linear transformations / e, f = translations if 'transform' in element.attrib: del element.attrib['transform'] element = ApplyTransformations.objectToPath(element) if transf == NULL_TRANSFORM: # Don't do anything if there is effectively no transform applied # reduces alerts for unsupported elements pass elif 'd' in element.attrib: d = element.get('d') p = CubicSuperPath(d) p = Path(p).to_absolute().transform(transf, True) element.set('d', str(Path(CubicSuperPath(p).to_path()))) self.scaleStyleAttrib(element, transf, 'stroke-width') elif element.tag in [inkex.addNS('polygon', 'svg'), inkex.addNS('polyline', 'svg')]: points = element.get('points') points = points.strip().split(' ') for k, p in enumerate(points): if ',' in p: p = p.split(',') p = [float(p[0]), float(p[1])] p = transf.apply_to_point(p) p = [str(p[0]), str(p[1])] p = ','.join(p) points[k] = p points = ' '.join(points) element.set('points', points) self.scaleStyleAttrib(element, transf, 'stroke-width') elif element.tag in [inkex.addNS("ellipse", "svg"), inkex.addNS("circle", "svg")]: def isequal(a, b): return abs(a - b) <= transf.absolute_tolerance if element.TAG == "ellipse": rx = float(element.get("rx")) ry = float(element.get("ry")) else: rx = float(element.get("r")) ry = rx cx = float(element.get("cx")) cy = float(element.get("cy")) sqxy1 = (cx - rx, cy - ry) sqxy2 = (cx + rx, cy - ry) sqxy3 = (cx + rx, cy + ry) newxy1 = transf.apply_to_point(sqxy1) newxy2 = transf.apply_to_point(sqxy2) newxy3 = transf.apply_to_point(sqxy3) element.set("cx", (newxy1[0] + newxy3[0]) / 2) element.set("cy", (newxy1[1] + newxy3[1]) / 2) edgex = math.sqrt( abs(newxy1[0] - newxy2[0]) ** 2 + abs(newxy1[1] - newxy2[1]) ** 2 ) edgey = math.sqrt( abs(newxy2[0] - newxy3[0]) ** 2 + abs(newxy2[1] - newxy3[1]) ** 2 ) if not isequal(edgex, edgey) and ( element.TAG == "circle" or not isequal(newxy2[0], newxy3[0]) or not isequal(newxy1[1], newxy2[1]) ): inkex.utils.errormsg(f"Warning: Shape {element.TAG} ({element.get('id')}) is approximate only, try Object to path first for better results") if element.TAG == "ellipse": element.set("rx", edgex / 2) element.set("ry", edgey / 2) else: element.set("r", edgex / 2) # this is unstable at the moment elif element.tag == inkex.addNS("use", "svg"): href = None old_href_key = '{http://www.w3.org/1999/xlink}href' new_href_key = 'href' if element.attrib.has_key(old_href_key) is True: # {http://www.w3.org/1999/xlink}href (which gets displayed as 'xlink:href') attribute is deprecated. the newer attribute is just 'href' href = element.attrib.get(old_href_key) #element.attrib.pop(old_href_key) if element.attrib.has_key(new_href_key) is True: href = element.attrib.get(new_href_key) #we might overwrite the previous deprecated xlink:href but it's okay #element.attrib.pop(new_href_key) #get the linked object from href attribute linkedObject = self.document.getroot().xpath("//*[@id = '%s']" % href.lstrip('#')) #we must remove hashtag symbol linkedObjectCopy = copy.copy(linkedObject[0]) objectType = linkedObject[0].tag if objectType == inkex.addNS("image", "svg"): mask = None #image might have an alpha channel new_mask_id = self.svg.get_unique_id("mask") newMask = None if element.attrib.has_key('mask') is True: mask = element.attrib.get('mask') #element.attrib.pop('mask') #get the linked mask from mask attribute. We remove the old and create a new if mask is not None: linkedMask = self.document.getroot().xpath("//*[@id = '%s']" % mask.lstrip('url(#').rstrip(')')) #we must remove hashtag symbol linkedMask[0].delete() maskAttributes = {'id': new_mask_id} newMask = etree.SubElement(self.document.getroot(), inkex.addNS('mask', 'svg'), maskAttributes) width = float(linkedObjectCopy.get('width')) * transf.a height = float(linkedObjectCopy.get('height')) * transf.d linkedObjectCopy.set('width', '{:1.6f}'.format(width)) linkedObjectCopy.set('height', '{:1.6f}'.format(height)) linkedObjectCopy.set('x', '{:1.6f}'.format(transf.e)) linkedObjectCopy.set('y', '{:1.6f}'.format(transf.f)) if newMask is not None: linkedObjectCopy.set('mask', 'url(#' + new_mask_id + ')') maskRectAttributes = {'x': '{:1.6f}'.format(transf.e), 'y': '{:1.6f}'.format(transf.f), 'width': '{:1.6f}'.format(width), 'height': '{:1.6f}'.format(height), 'style':'fill:#ffffff;'} maskRect = etree.SubElement(newMask, inkex.addNS('rect', 'svg'), maskRectAttributes) self.document.getroot().append(linkedObjectCopy) #for each svg:use we append a copy to the document root element.delete() #then we remove the use object else: #self.recursiveFuseTransform(linkedObjectCopy, transf) self.recursiveFuseTransform(element.unlink(), transf) elif element.tag == inkex.addNS('rect', 'svg'): self.transformRectangle(element, transf) self.scaleStyleAttrib(element, transf, 'stroke-width') elif element.tag in [inkex.addNS('text', 'svg')]: self.transformText(element, transf) self.scaleStyleAttrib(element, transf, 'font-size') elif element.tag in [inkex.addNS('tspan', 'svg')]: self.transformTspan(element, transf) self.scaleStyleAttrib(element, transf, 'font-size') elif element.tag in [inkex.addNS('image', 'svg'), inkex.addNS('use', 'svg')]: element.attrib['transform'] = str(transf) inkex.utils.errormsg(f"Shape {element.TAG} ({element.get('id')}) not yet supported. Not all transforms will be applied. Try Object to path first") else: # e.g. self.scaleStyleAttrib(element, transf, 'stroke-width') for child in element.getchildren(): self.recursiveFuseTransform(child, transf) if __name__ == '__main__': ApplyTransformations().run()