/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ide.common.vectordrawable; import com.android.annotations.NonNull; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.io.*; import java.util.HashSet; import java.util.logging.Level; import java.util.logging.Logger; /** * Converts SVG to VectorDrawable's XML */ public class Svg2Vector { private static Logger logger = Logger.getLogger(Svg2Vector.class.getSimpleName()); public static final String SVG_POLYGON = "polygon"; public static final String SVG_RECT = "rect"; public static final String SVG_CIRCLE = "circle"; public static final String SVG_LINE = "line"; public static final String SVG_PATH = "path"; public static final String SVG_GROUP = "g"; public static final String SVG_TRANSFORM = "transform"; public static final String SVG_WIDTH = "width"; public static final String SVG_HEIGHT = "height"; public static final String SVG_VIEW_BOX = "viewBox"; public static final String SVG_STYLE = "style"; public static final String SVG_DISPLAY = "display"; public static final String SVG_D = "d"; public static final String SVG_STROKE_COLOR = "stroke"; public static final String SVG_STROKE_OPACITY = "stroke-opacity"; public static final String SVG_STROKE_LINEJOINE = "stroke-linejoin"; public static final String SVG_STROKE_LINECAP = "stroke-linecap"; public static final String SVG_STROKE_WIDTH = "stroke-width"; public static final String SVG_FILL_COLOR = "fill"; public static final String SVG_FILL_OPACITY = "fill-opacity"; public static final String SVG_OPACITY = "opacity"; public static final String SVG_CLIP = "clip"; public static final String SVG_POINTS = "points"; public static final ImmutableMap<String, String> presentationMap = ImmutableMap.<String, String>builder() .put(SVG_STROKE_COLOR, "android:strokeColor") .put(SVG_STROKE_OPACITY, "android:strokeAlpha") .put(SVG_STROKE_LINEJOINE, "android:strokeLinejoin") .put(SVG_STROKE_LINECAP, "android:strokeLinecap") .put(SVG_STROKE_WIDTH, "android:strokeWidth") .put(SVG_FILL_COLOR, "android:fillColor") .put(SVG_FILL_OPACITY, "android:fillAlpha") .put(SVG_CLIP, "android:clip").put(SVG_OPACITY, "android:fillAlpha") .build(); // List all the Svg nodes that we don't support. Categorized by the types. private static final HashSet<String> unsupportedSvgNodes = Sets.newHashSet( // Animation elements "animate", "animateColor", "animateMotion", "animateTransform", "mpath", "set", // Container elements "a", "defs", "glyph", "marker", "mask", "missing-glyph", "pattern", "switch", "symbol", // Filter primitive elements "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset", "feSpecularLighting", "feTile", "feTurbulence", // Font elements "font", "font-face", "font-face-format", "font-face-name", "font-face-src", "font-face-uri", "hkern", "vkern", // Gradient elements "linearGradient", "radialGradient", "stop", // Graphics elements "ellipse", "polyline", "text", "use", // Light source elements "feDistantLight", "fePointLight", "feSpotLight", // Structural elements "defs", "symbol", "use", // Text content elements "altGlyph", "altGlyphDef", "altGlyphItem", "glyph", "glyphRef", "textPath", "text", "tref", "tspan", // Text content child elements "altGlyph", "textPath", "tref", "tspan", // Uncategorized elements "clipPath", "color-profile", "cursor", "filter", "foreignObject", "script", "view"); @NonNull private static SvgTree parse(File f) throws Exception { SvgTree svgTree = new SvgTree(); Document doc = svgTree.parse(f); NodeList nSvgNode; // Parse svg elements nSvgNode = doc.getElementsByTagName("svg"); if (nSvgNode.getLength() != 1) { throw new IllegalStateException("Not a proper SVG file"); } Node rootNode = nSvgNode.item(0); for (int i = 0; i < nSvgNode.getLength(); i++) { Node nNode = nSvgNode.item(i); if (nNode.getNodeType() == Node.ELEMENT_NODE) { parseDimension(svgTree, nNode); } } if (svgTree.viewBox == null) { svgTree.logErrorLine("Missing \"viewBox\" in <svg> element", rootNode, SvgTree.SvgLogLevel.ERROR); return svgTree; } if ((svgTree.w == 0 || svgTree.h == 0) && svgTree.viewBox[2] > 0 && svgTree.viewBox[3] > 0) { svgTree.w = svgTree.viewBox[2]; svgTree.h = svgTree.viewBox[3]; } // Parse transformation information. // TODO: Properly handle transformation in the group level. In the "use" case, we treat // it as global for now. NodeList nUseTags; svgTree.matrix = new float[6]; svgTree.matrix[0] = 1; svgTree.matrix[3] = 1; nUseTags = doc.getElementsByTagName("use"); for (int temp = 0; temp < nUseTags.getLength(); temp++) { Node nNode = nUseTags.item(temp); if (nNode.getNodeType() == Node.ELEMENT_NODE) { parseTransformation(svgTree, nNode); } } SvgGroupNode root = new SvgGroupNode(svgTree, rootNode, "root"); svgTree.setRoot(root); // Parse all the group and path node recursively. traverseSVGAndExtract(svgTree, root, rootNode); svgTree.dump(root); return svgTree; } private static void traverseSVGAndExtract(SvgTree svgTree, SvgGroupNode currentGroup, Node item) { // Recursively traverse all the group and path nodes NodeList allChildren = item.getChildNodes(); for (int i = 0; i < allChildren.getLength(); i++) { Node currentNode = allChildren.item(i); String nodeName = currentNode.getNodeName(); if (SVG_PATH.equals(nodeName) || SVG_RECT.equals(nodeName) || SVG_CIRCLE.equals(nodeName) || SVG_POLYGON.equals(nodeName) || SVG_LINE.equals(nodeName)) { SvgLeafNode child = new SvgLeafNode(svgTree, currentNode, nodeName + i); extractAllItemsAs(svgTree, child, currentNode); currentGroup.addChild(child); } else if (SVG_GROUP.equals(nodeName)) { SvgGroupNode childGroup = new SvgGroupNode(svgTree, currentNode, "child" + i); currentGroup.addChild(childGroup); traverseSVGAndExtract(svgTree, childGroup, currentNode); } else { // For other fancy tags, like <refs>, they can contain children too. // Report the unsupported nodes. if (unsupportedSvgNodes.contains(nodeName)) { svgTree.logErrorLine("<" + nodeName + "> is not supported", currentNode, SvgTree.SvgLogLevel.ERROR); } traverseSVGAndExtract(svgTree, currentGroup, currentNode); } } } private static void parseTransformation(SvgTree avg, Node nNode) { NamedNodeMap a = nNode.getAttributes(); int len = a.getLength(); for (int i = 0; i < len; i++) { Node n = a.item(i); String name = n.getNodeName(); String value = n.getNodeValue(); if (SVG_TRANSFORM.equals(name)) { if (value.startsWith("matrix(")) { value = value.substring("matrix(".length(), value.length() - 1); String[] sp = value.split(" "); for (int j = 0; j < sp.length; j++) { avg.matrix[j] = Float.parseFloat(sp[j]); } } } else if (name.equals("y")) { Float.parseFloat(value); } else if (name.equals("x")) { Float.parseFloat(value); } } } private static void parseDimension(SvgTree avg, Node nNode) { NamedNodeMap a = nNode.getAttributes(); int len = a.getLength(); for (int i = 0; i < len; i++) { Node n = a.item(i); String name = n.getNodeName(); String value = n.getNodeValue(); int subStringSize = value.length(); if (subStringSize > 2) { if (value.endsWith("px")) { subStringSize = subStringSize - 2; } } if (SVG_WIDTH.equals(name)) { avg.w = Float.parseFloat(value.substring(0, subStringSize)); } else if (SVG_HEIGHT.equals(name)) { avg.h = Float.parseFloat(value.substring(0, subStringSize)); } else if (SVG_VIEW_BOX.equals(name)) { avg.viewBox = new float[4]; String[] strbox = value.split(" "); for (int j = 0; j < avg.viewBox.length; j++) { avg.viewBox[j] = Float.parseFloat(strbox[j]); } } } if (avg.viewBox == null && avg.w != 0 && avg.h != 0) { avg.viewBox = new float[4]; avg.viewBox[2] = avg.w; avg.viewBox[3] = avg.h; } } // Read the content from currentItem, and fill into "child" private static void extractAllItemsAs(SvgTree avg, SvgLeafNode child, Node currentItem) { Node currentGroup = currentItem.getParentNode(); boolean hasNodeAttr = false; String styleContent = ""; boolean nothingToDisplay = false; while (currentGroup != null && currentGroup.getNodeName().equals("g")) { // Parse the group's attributes. logger.log(Level.FINE, "Printing current parent"); printlnCommon(currentGroup); NamedNodeMap attr = currentGroup.getAttributes(); Node nodeAttr = attr.getNamedItem(SVG_STYLE); // Search for the "display:none", if existed, then skip this item. if (nodeAttr != null) { styleContent += nodeAttr.getTextContent() + ";"; logger.log(Level.FINE, "styleContent is :" + styleContent + "at number group "); if (styleContent.contains("display:none")) { logger.log(Level.FINE, "Found none style, skip the whole group"); nothingToDisplay = true; break; } else { hasNodeAttr = true; } } Node displayAttr = attr.getNamedItem(SVG_DISPLAY); if (displayAttr != null && "none".equals(displayAttr.getNodeValue())) { logger.log(Level.FINE, "Found display:none style, skip the whole group"); nothingToDisplay = true; break; } currentGroup = currentGroup.getParentNode(); } if (nothingToDisplay) { // Skip this current whole item. return; } logger.log(Level.FINE, "Print current item"); printlnCommon(currentItem); if (hasNodeAttr && styleContent != null) { addStyleToPath(child, styleContent); } Node currentGroupNode = currentItem; if (SVG_PATH.equals(currentGroupNode.getNodeName())) { extractPathItem(avg, child, currentGroupNode); } if (SVG_RECT.equals(currentGroupNode.getNodeName())) { extractRectItem(avg, child, currentGroupNode); } if (SVG_CIRCLE.equals(currentGroupNode.getNodeName())) { extractCircleItem(avg, child, currentGroupNode); } if (SVG_POLYGON.equals(currentGroupNode.getNodeName())) { extractPolyItem(avg, child, currentGroupNode); } if (SVG_LINE.equals(currentGroupNode.getNodeName())) { extractLineItem(avg, child, currentGroupNode); } } private static void printlnCommon(Node n) { logger.log(Level.FINE, " nodeName=\"" + n.getNodeName() + "\""); String val = n.getNamespaceURI(); if (val != null) { logger.log(Level.FINE, " uri=\"" + val + "\""); } val = n.getPrefix(); if (val != null) { logger.log(Level.FINE, " pre=\"" + val + "\""); } val = n.getLocalName(); if (val != null) { logger.log(Level.FINE, " local=\"" + val + "\""); } val = n.getNodeValue(); if (val != null) { logger.log(Level.FINE, " nodeValue="); if (val.trim().equals("")) { // Whitespace logger.log(Level.FINE, "[WS]"); } else { logger.log(Level.FINE, "\"" + n.getNodeValue() + "\""); } } } /** * Convert polygon element into a path. */ private static void extractPolyItem(SvgTree avg, SvgLeafNode child, Node currentGroupNode) { logger.log(Level.FINE, "Rect found" + currentGroupNode.getTextContent()); if (currentGroupNode.getNodeType() == Node.ELEMENT_NODE) { NamedNodeMap a = currentGroupNode.getAttributes(); int len = a.getLength(); for (int itemIndex = 0; itemIndex < len; itemIndex++) { Node n = a.item(itemIndex); String name = n.getNodeName(); String value = n.getNodeValue(); if (name.equals(SVG_STYLE)) { addStyleToPath(child, value); } else if (presentationMap.containsKey(name)) { child.fillPresentationAttributes(name, value); } else if (name.equals(SVG_POINTS)) { PathBuilder builder = new PathBuilder(); String[] split = value.split("[\\s,]+"); float baseX = Float.parseFloat(split[0]); float baseY = Float.parseFloat(split[1]); builder.absoluteMoveTo(baseX, baseY); for (int j = 2; j < split.length; j += 2) { float x = Float.parseFloat(split[j]); float y = Float.parseFloat(split[j + 1]); builder.relativeLineTo(x - baseX, y - baseY); baseX = x; baseY = y; } builder.relativeClose(); child.setPathData(builder.toString()); } } } } /** * Convert rectangle element into a path. */ private static void extractRectItem(SvgTree avg, SvgLeafNode child, Node currentGroupNode) { logger.log(Level.FINE, "Rect found" + currentGroupNode.getTextContent()); if (currentGroupNode.getNodeType() == Node.ELEMENT_NODE) { float x = 0; float y = 0; float width = Float.NaN; float height = Float.NaN; NamedNodeMap a = currentGroupNode.getAttributes(); int len = a.getLength(); boolean pureTransparent = false; for (int j = 0; j < len; j++) { Node n = a.item(j); String name = n.getNodeName(); String value = n.getNodeValue(); if (name.equals(SVG_STYLE)) { addStyleToPath(child, value); if (value.contains("opacity:0;")) { pureTransparent = true; } } else if (presentationMap.containsKey(name)) { child.fillPresentationAttributes(name, value); } else if (name.equals("clip-path") && value.startsWith("url(#SVGID_")) { } else if (name.equals("x")) { x = Float.parseFloat(value); } else if (name.equals("y")) { y = Float.parseFloat(value); } else if (name.equals("width")) { width = Float.parseFloat(value); } else if (name.equals("height")) { height = Float.parseFloat(value); } else if (name.equals("style")) { } } if (!pureTransparent && avg != null && !Float.isNaN(x) && !Float.isNaN(y) && !Float.isNaN(width) && !Float.isNaN(height)) { // "M x, y h width v height h -width z" PathBuilder builder = new PathBuilder(); builder.absoluteMoveTo(x, y); builder.relativeHorizontalTo(width); builder.relativeVerticalTo(height); builder.relativeHorizontalTo(-width); builder.relativeClose(); child.setPathData(builder.toString()); } } } /** * Convert circle element into a path. */ private static void extractCircleItem(SvgTree avg, SvgLeafNode child, Node currentGroupNode) { logger.log(Level.FINE, "circle found" + currentGroupNode.getTextContent()); if (currentGroupNode.getNodeType() == Node.ELEMENT_NODE) { float cx = 0; float cy = 0; float radius = 0; NamedNodeMap a = currentGroupNode.getAttributes(); int len = a.getLength(); boolean pureTransparent = false; for (int j = 0; j < len; j++) { Node n = a.item(j); String name = n.getNodeName(); String value = n.getNodeValue(); if (name.equals(SVG_STYLE)) { addStyleToPath(child, value); if (value.contains("opacity:0;")) { pureTransparent = true; } } else if (presentationMap.containsKey(name)) { child.fillPresentationAttributes(name, value); } else if (name.equals("clip-path") && value.startsWith("url(#SVGID_")) { } else if (name.equals("cx")) { cx = Float.parseFloat(value); } else if (name.equals("cy")) { cy = Float.parseFloat(value); } else if (name.equals("r")) { radius = Float.parseFloat(value); } } if (!pureTransparent && avg != null && !Float.isNaN(cx) && !Float.isNaN(cy)) { // "M cx cy m -r, 0 a r,r 0 1,1 (r * 2),0 a r,r 0 1,1 -(r * 2),0" PathBuilder builder = new PathBuilder(); builder.absoluteMoveTo(cx, cy); builder.relativeMoveTo(-radius, 0); builder.relativeArcTo(radius, radius, false, true, true, 2 * radius, 0); builder.relativeArcTo(radius, radius, false, true, true, -2 * radius, 0); child.setPathData(builder.toString()); } } } /** * Convert line element into a path. */ private static void extractLineItem(SvgTree avg, SvgLeafNode child, Node currentGroupNode) { logger.log(Level.FINE, "line found" + currentGroupNode.getTextContent()); if (currentGroupNode.getNodeType() == Node.ELEMENT_NODE) { float x1 = 0; float y1 = 0; float x2 = 0; float y2 = 0; NamedNodeMap a = currentGroupNode.getAttributes(); int len = a.getLength(); boolean pureTransparent = false; for (int j = 0; j < len; j++) { Node n = a.item(j); String name = n.getNodeName(); String value = n.getNodeValue(); if (name.equals(SVG_STYLE)) { addStyleToPath(child, value); if (value.contains("opacity:0;")) { pureTransparent = true; } } else if (presentationMap.containsKey(name)) { child.fillPresentationAttributes(name, value); } else if (name.equals("clip-path") && value.startsWith("url(#SVGID_")) { // TODO: Handle clip path here. } else if (name.equals("x1")) { x1 = Float.parseFloat(value); } else if (name.equals("y1")) { y1 = Float.parseFloat(value); } else if (name.equals("x2")) { x2 = Float.parseFloat(value); } else if (name.equals("y2")) { y2 = Float.parseFloat(value); } } if (!pureTransparent && avg != null && !Float.isNaN(x1) && !Float.isNaN(y1) && !Float.isNaN(x2) && !Float.isNaN(y2)) { // "M x1, y1 L x2, y2" PathBuilder builder = new PathBuilder(); builder.absoluteMoveTo(x1, y1); builder.absoluteLineTo(x2, y2); child.setPathData(builder.toString()); } } } private static void extractPathItem(SvgTree avg, SvgLeafNode child, Node currentGroupNode) { logger.log(Level.FINE, "Path found " + currentGroupNode.getTextContent()); if (currentGroupNode.getNodeType() == Node.ELEMENT_NODE) { Element eElement = (Element)currentGroupNode; NamedNodeMap a = currentGroupNode.getAttributes(); int len = a.getLength(); for (int j = 0; j < len; j++) { Node n = a.item(j); String name = n.getNodeName(); String value = n.getNodeValue(); if (name.equals(SVG_STYLE)) { addStyleToPath(child, value); } else if (presentationMap.containsKey(name)) { child.fillPresentationAttributes(name, value); } else if (name.equals(SVG_D)) { String pathData = value.replaceAll("(\\d)-", "$1,-"); child.setPathData(pathData); } } } } private static void addStyleToPath(SvgLeafNode path, String value) { logger.log(Level.FINE, "Style found is " + value); if (value != null) { String[] parts = value.split(";"); for (int k = parts.length - 1; k >= 0; k--) { String subStyle = parts[k]; String[] nameValue = subStyle.split(":"); if (nameValue.length == 2 && nameValue[0] != null && nameValue[1] != null) { if (presentationMap.containsKey(nameValue[0])) { path.fillPresentationAttributes(nameValue[0], nameValue[1]); } else if (nameValue[0].equals(SVG_OPACITY)) { // TODO: This is hacky, since we don't have a group level // android:opacity. This only works when the path didn't overlap. path.fillPresentationAttributes(SVG_FILL_OPACITY, nameValue[1]); } } } } } private static final String head = "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n"; private static String getSizeString(float w, float h, float scaleFactor) { String size = " android:width=\"" + (int) (w * scaleFactor) + "dp\"\n" + " android:height=\"" + (int) (h * scaleFactor) + "dp\"\n"; return size; } private static void writeFile(OutputStream outStream, SvgTree svgTree) throws IOException { OutputStreamWriter fw = new OutputStreamWriter(outStream); fw.write(head); float finalWidth = svgTree.w; float finalHeight = svgTree.h; fw.write(getSizeString(finalWidth, finalHeight, svgTree.mScaleFactor)); fw.write(" android:viewportWidth=\"" + svgTree.w + "\"\n"); fw.write(" android:viewportHeight=\"" + svgTree.h + "\">\n"); svgTree.normalize(); // TODO: this has to happen in the tree mode!!! writeXML(svgTree, fw); fw.write("</vector>\n"); fw.close(); } private static void writeXML(SvgTree svgTree, OutputStreamWriter fw) throws IOException { svgTree.getRoot().writeXML(fw); } /** * Convert a SVG file into VectorDrawable's XML content, if no error is found. * * @param inputSVG the input SVG file * @param outStream the converted VectorDrawable's content. This can be * empty if there is any error found during parsing * @return the error messages, which contain things like all the tags * VectorDrawble don't support or exception message. */ public static String parseSvgToXml(File inputSVG, OutputStream outStream) { // Write all the error message during parsing into SvgTree. and return here as getErrorLog(). // We will also log the exceptions here. String errorLog = null; try { SvgTree svgTree = parse(inputSVG); errorLog = svgTree.getErrorLog(); // When there was anything in the input SVG file that we can't // convert to VectorDrawable, we logged them as errors. // After we logged all the errors, we skipped the XML file generation. if (svgTree.canConvertToVectorDrawable()) { writeFile(outStream, svgTree); } } catch (Exception e) { errorLog = "EXCEPTION in parsing " + inputSVG.getName() + ":\n" + e.getMessage(); } return errorLog; } }