| /* |
| * Copyright (C) 2020 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.tools.idea.rendering; |
| |
| import static com.android.SdkConstants.ANDROID_URI; |
| import static com.android.utils.XmlUtils.formatFloatValue; |
| import static java.lang.Math.min; |
| |
| import com.android.SdkConstants; |
| import com.android.tools.idea.res.IdeResourcesUtil; |
| import com.android.utils.CharSequences; |
| import com.google.common.collect.ImmutableSet; |
| import com.intellij.application.options.CodeStyle; |
| import com.intellij.openapi.util.text.StringUtil; |
| import com.intellij.util.LineSeparator; |
| import java.awt.Color; |
| import java.awt.Dimension; |
| import java.awt.geom.Point2D; |
| import java.awt.geom.Rectangle2D; |
| import java.io.IOException; |
| import java.util.Objects; |
| import org.jetbrains.annotations.NotNull; |
| import org.jetbrains.annotations.Nullable; |
| import org.kxml2.io.KXmlParser; |
| import org.xmlpull.v1.XmlPullParser; |
| import org.xmlpull.v1.XmlPullParserException; |
| |
| /** |
| * Methods for manipulating vector drawables. |
| */ |
| public class VectorDrawableTransformer { |
| private static final ImmutableSet<String> NAMES_OF_HANDLED_ATTRIBUTES = |
| ImmutableSet.of("width", "height", "viewportWidth", "viewportHeight", "tint", "alpha"); |
| private static final String INDENT = " "; |
| private static final String DOUBLE_INDENT = INDENT + INDENT; |
| |
| /** Do not instantiate. All methods are static. */ |
| private VectorDrawableTransformer() {} |
| |
| /** |
| * Transforms a vector drawable to fit in a rectangle with the {@code targetSize} dimensions. |
| * |
| * @param originalDrawable the original drawable, preserved intact by the method |
| * @param targetSize the size of the target rectangle |
| * @return the transformed drawable; may be the same as the original if no transformation was |
| * required, or if the drawable is not a vector one |
| */ |
| @NotNull |
| public static String transform(@NotNull String originalDrawable, @NotNull Dimension targetSize) { |
| return transform(originalDrawable, targetSize, Gravity.CENTER, 1, null, null, null, 1); |
| } |
| |
| /** |
| * Transforms a vector drawable to fit in a rectangle with the {@code targetSize} dimensions and optionally |
| * applies tint and opacity to it. |
| * Conceptually, the geometric transformation includes of the following steps: |
| * <ul> |
| * <li>The drawable is resized and centered in a rectangle of the target size</li> |
| * <li>If {@code clipRectangle} is not null, the drawable is clipped, resized and re-centered again</li> |
| * <li>The drawable is scaled according to {@code scaleFactor}</li> |
| * <li>The drawable is either padded or clipped to fit into the target rectangle</li> |
| * <li>If {@code shift} is not null, the drawable is shifted</li> |
| * </ul> |
| * |
| * @param originalDrawable the original drawable, preserved intact by the method |
| * @param targetSize the size of the target rectangle |
| * @param gravity determines alignment of the original image in the target rectangle |
| * @param scaleFactor a scale factor to apply |
| * @param clipRectangle an optional clip rectangle in coordinates expressed as fraction of the {@code targetSize} |
| * @param shift an optional shift vector in coordinates expressed as fraction of the {@code targetSize} |
| * @param tint an optional tint to apply to the drawable |
| * @param opacity opacity to apply to the drawable |
| * @return the transformed drawable; may be the same as the original if no transformation was |
| * required, or if the drawable is not a vector one |
| */ |
| @NotNull |
| public static String transform(@NotNull String originalDrawable, |
| @NotNull Dimension targetSize, |
| @NotNull Gravity gravity, |
| double scaleFactor, |
| @Nullable Rectangle2D clipRectangle, |
| @Nullable Point2D shift, |
| @Nullable Color tint, |
| double opacity) { |
| KXmlParser parser = new KXmlParser(); |
| |
| try { |
| parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); |
| parser.setInput(CharSequences.getReader(originalDrawable, true)); |
| int startLine = 1; |
| int startColumn = 1; |
| int token; |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.START_TAG) { |
| startLine = parser.getLineNumber(); |
| startColumn = parser.getColumnNumber(); |
| } |
| // Skip to the first tag. |
| if (parser.getEventType() != XmlPullParser.START_TAG || !"vector".equals(parser.getName()) || parser.getPrefix() != null) { |
| return originalDrawable; // Not a vector drawable. |
| } |
| |
| String originalTintValue = parser.getAttributeValue(ANDROID_URI, "tint"); |
| String tintValue = tint == null ? originalTintValue : IdeResourcesUtil.colorToString(tint); |
| |
| String originalAlphaValue = parser.getAttributeValue(ANDROID_URI, "alpha"); |
| if (originalAlphaValue != null) { |
| opacity *= parseDoubleValue(originalAlphaValue, ""); |
| } |
| String alphaValue = formatFloatValue(opacity); |
| if (alphaValue.equals("1")) { |
| alphaValue = null; // No need to set the default opacity. |
| } |
| |
| double targetWidth = targetSize.getWidth(); |
| double targetHeight = targetSize.getHeight(); |
| double width = targetWidth; |
| double height = targetHeight; |
| double originalViewportWidth = getDoubleAttributeValue(parser, ANDROID_URI, "viewportWidth", ""); |
| double originalViewportHeight = getDoubleAttributeValue(parser, ANDROID_URI, "viewportHeight", ""); |
| String widthValue = parser.getAttributeValue(ANDROID_URI, "width"); |
| if (widthValue != null) { |
| String suffix = getSuffix(widthValue); |
| width = getDoubleAttributeValue(parser, ANDROID_URI, "width", suffix); |
| height = getDoubleAttributeValue(parser, ANDROID_URI, "height", suffix); |
| |
| //noinspection FloatingPointEquality -- safe in this context since all integer values are representable as double. |
| if (suffix.equals("dp") && width == targetWidth && height == targetHeight && |
| originalViewportWidth == targetWidth && originalViewportHeight == targetHeight && |
| scaleFactor == 1 && clipRectangle == null && |
| Objects.equals(tintValue, originalTintValue) && Objects.equals(alphaValue, originalAlphaValue)) { |
| return originalDrawable; // No transformation is needed. |
| } |
| if (Double.isNaN(width) || width == 0 || Double.isNaN(height) || height == 0) { |
| width = targetWidth; |
| height = targetHeight; |
| } |
| } |
| |
| // Components of the translation vector in viewport coordinates. |
| double x = 0; |
| double y = 0; |
| if (clipRectangle != null) { |
| // Adjust scale. |
| scaleFactor /= Math.max(clipRectangle.getWidth(), clipRectangle.getHeight()); |
| // Re-center the image relative to the clip rectangle. |
| x += (0.5 - clipRectangle.getCenterX()) * targetWidth * scaleFactor; |
| y += (0.5 - clipRectangle.getCenterY()) * targetHeight * scaleFactor; |
| } |
| |
| if (Double.isNaN(originalViewportWidth) || originalViewportWidth == 0 || |
| Double.isNaN(originalViewportHeight) || originalViewportHeight == 0) { |
| originalViewportWidth = width; |
| originalViewportHeight = height; |
| } |
| |
| double ratio = width * originalViewportHeight / (height * originalViewportWidth); |
| if (ratio > 1) { |
| y += 0.5 * targetWidth * ratio; |
| } |
| else if (ratio < 1) { |
| x += 0.5 * targetHeight / ratio; |
| } |
| x += 0.5 * targetWidth * (1 - scaleFactor); |
| y += 0.5 * targetHeight * (1 - scaleFactor); |
| |
| ratio = targetWidth * originalViewportHeight / (targetHeight * originalViewportWidth); |
| if (ratio > 1) { |
| double alignmentScale = (gravity.getHorizontalAlignment() + 1) / 2.; |
| x += alignmentScale * targetWidth * (1 - 1 / ratio) * scaleFactor; |
| } |
| else if (ratio < 1) { |
| double alignmentScale = (gravity.getVerticalAlignment() + 1) / 2.; |
| y += alignmentScale * targetHeight * (1 - ratio) * scaleFactor; |
| } |
| |
| scaleFactor *= min(targetWidth / originalViewportWidth, targetHeight / originalViewportHeight); |
| |
| if (shift != null) { |
| x += targetWidth * shift.getX(); |
| y += targetHeight * shift.getY(); |
| } |
| |
| StringBuilder result = new StringBuilder(originalDrawable.length() + originalDrawable.length() / 8); |
| |
| Indenter indenter = new Indenter(originalDrawable); |
| // Copy contents before the first element. |
| indenter.copy(1, 1, startLine, startColumn, "", result); |
| String lineSeparator = detectLineSeparator(originalDrawable); |
| // Output the "vector" element with the xmlns:android attribute. |
| result.append(String.format("<vector %s:%s=\"%s\"", SdkConstants.XMLNS, SdkConstants.ANDROID_NS_NAME, ANDROID_URI)); |
| // Copy remaining namespace attributes. |
| for (int i = 0; i < parser.getNamespaceCount(1); i++) { |
| String prefix = parser.getNamespacePrefix(i); |
| String uri = parser.getNamespaceUri(i); |
| if (!SdkConstants.ANDROID_NS_NAME.equals(prefix) || !ANDROID_URI.equals(uri)) { |
| result.append(String.format("%s%s%s:%s=\"%s\"", lineSeparator, DOUBLE_INDENT, SdkConstants.XMLNS, prefix, uri)); |
| } |
| } |
| |
| result.append(String.format("%s%sandroid:width=\"%sdp\"", lineSeparator, DOUBLE_INDENT, formatFloatValue(targetWidth))); |
| result.append(String.format("%s%sandroid:height=\"%sdp\"", lineSeparator, DOUBLE_INDENT, formatFloatValue(targetHeight))); |
| result.append(String.format("%s%sandroid:viewportWidth=\"%s\"", lineSeparator, DOUBLE_INDENT, formatFloatValue(targetWidth))); |
| result.append(String.format("%s%sandroid:viewportHeight=\"%s\"", lineSeparator, DOUBLE_INDENT, formatFloatValue(targetHeight))); |
| if (tintValue != null) { |
| result.append(String.format("%s%sandroid:tint=\"%s\"", lineSeparator, DOUBLE_INDENT, tintValue)); |
| } |
| if (alphaValue != null) { |
| result.append(String.format("%s%sandroid:alpha=\"%s\"", lineSeparator, DOUBLE_INDENT, alphaValue)); |
| } |
| |
| // Copy remaining attributes. |
| for (int i = 0; i < parser.getAttributeCount(); i++) { |
| String prefix = parser.getAttributePrefix(i); |
| String name = parser.getAttributeName(i); |
| if (!SdkConstants.ANDROID_NS_NAME.equals(prefix) || !NAMES_OF_HANDLED_ATTRIBUTES.contains(name)) { |
| if (prefix != null) { |
| name = prefix + ':' + name; |
| } |
| result.append(String.format("%s%s%s=\"%s\"", lineSeparator, DOUBLE_INDENT, name, parser.getAttributeValue(i))); |
| } |
| } |
| result.append('>'); |
| |
| String indent = ""; |
| int copyDepth = 2; |
| startLine = parser.getLineNumber(); |
| startColumn = parser.getColumnNumber(); |
| String translateX = isSignificantlyDifferentFromZero(x / targetWidth) ? formatFloatValue(x) : null; |
| String translateY = isSignificantlyDifferentFromZero(y / targetHeight) ? formatFloatValue(y) : null; |
| String scale = formatFloatValue(scaleFactor); |
| if (!scale.equals("1") || translateX != null || translateY != null) { |
| // Wrap contents of the drawable into a translation group. |
| result.append(lineSeparator).append(INDENT); |
| result.append("<group"); |
| String delimiter = " "; |
| if (!scale.equals("1")) { |
| result.append(String.format("%sandroid:scaleX=\"%s\"", delimiter, scale)); |
| delimiter = lineSeparator + INDENT + DOUBLE_INDENT; |
| result.append(String.format("%sandroid:scaleY=\"%s\"", delimiter, scale)); |
| } |
| if (translateX != null) { |
| result.append(String.format("%sandroid:translateX=\"%s\"", delimiter, translateX)); |
| delimiter = lineSeparator + INDENT + DOUBLE_INDENT; |
| } |
| if (translateY != null) { |
| result.append(String.format("%sandroid:translateY=\"%s\"", delimiter, translateY)); |
| } |
| result.append('>'); |
| indent = INDENT; |
| } |
| |
| // Copy contents before the </vector> tag. |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.END_TAG || |
| parser.getDepth() >= copyDepth) { |
| int endLineNumber = parser.getLineNumber(); |
| int endColumnNumber = parser.getColumnNumber(); |
| indenter.copy(startLine, startColumn, endLineNumber, endColumnNumber, token == XmlPullParser.CDSECT ? "" : indent, result); |
| startLine = endLineNumber; |
| startColumn = endColumnNumber; |
| } |
| if (startColumn > INDENT.length() + 1) { |
| result.append(lineSeparator); |
| startColumn = 1; |
| } |
| if (!scale.equals("1") || translateX != null || translateY != null) { |
| if (startColumn == 1) { |
| result.append(INDENT); |
| } |
| result.append(String.format("</group>%s", lineSeparator)); |
| } |
| |
| // Copy the closing </group> tag, the </vector> tag and the remainder of the document. |
| for (; token != XmlPullParser.END_DOCUMENT; token = parser.nextToken()) { |
| int endLineNumber = parser.getLineNumber(); |
| int endColumnNumber = parser.getColumnNumber(); |
| indenter.copy(startLine, startColumn, endLineNumber, endColumnNumber, "", result); |
| startLine = endLineNumber; |
| startColumn = endColumnNumber; |
| } |
| |
| return result.toString(); |
| } |
| catch (XmlPullParserException | IOException e) { |
| return originalDrawable; // Ignore and return the original drawable. |
| } |
| } |
| |
| /** |
| * Merges two vector drawables. The drawables must have identical width, height, viewport width and viewport height. |
| * |
| * @param drawable1 the first drawable to merge |
| * @param drawable2 the second drawable to merge |
| * @return the merged drawable |
| */ |
| public static String merge(@NotNull String drawable1, @NotNull String drawable2) { |
| KXmlParser parser = new KXmlParser(); |
| |
| try { |
| parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); |
| parser.setInput(CharSequences.getReader(drawable1, true)); |
| int token; |
| // Skip to the first tag. |
| //noinspection StatementWithEmptyBody |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.START_TAG) { |
| } |
| if (parser.getEventType() != XmlPullParser.START_TAG || !"vector".equals(parser.getName()) || parser.getPrefix() != null) { |
| return drawable1; // Not a vector drawable. |
| } |
| |
| StringBuilder result = new StringBuilder(drawable1.length() + drawable2.length()); |
| |
| Indenter indenter = new Indenter(drawable1); |
| // Copy contents of the first drawable before the </vector> tag. |
| int startLine = 1; |
| int startColumn = 1; |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.END_TAG || |
| parser.getDepth() > 1) { |
| int endLineNumber = parser.getLineNumber(); |
| int endColumnNumber = parser.getColumnNumber(); |
| indenter.copy(startLine, startColumn, endLineNumber, endColumnNumber, "", result); |
| startLine = endLineNumber; |
| startColumn = endColumnNumber; |
| } |
| |
| parser.setInput(CharSequences.getReader(drawable2, true)); |
| //noinspection StatementWithEmptyBody |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.START_TAG) { |
| } |
| // Skip to the first tag. |
| if (parser.getEventType() != XmlPullParser.START_TAG || !"vector".equals(parser.getName()) || parser.getPrefix() != null) { |
| return drawable1; // Not a vector drawable. |
| } |
| startLine = parser.getLineNumber(); |
| startColumn = parser.getColumnNumber(); |
| |
| indenter = new Indenter(drawable2); |
| // Copy contents of the second drawable after the opening <vector> tag. |
| while (parser.nextToken() != XmlPullParser.END_DOCUMENT) { |
| int endLineNumber = parser.getLineNumber(); |
| int endColumnNumber = parser.getColumnNumber(); |
| indenter.copy(startLine, startColumn, endLineNumber, endColumnNumber, "", result); |
| startLine = endLineNumber; |
| startColumn = endColumnNumber; |
| } |
| |
| return result.toString(); |
| } |
| catch (XmlPullParserException | IOException e) { |
| return drawable1; // Ignore and return the original drawable. |
| } |
| } |
| |
| /** |
| * Returns viewport size of the vector drawable, or null if the parameter is not a valid vector drawable. |
| * |
| * @param drawable XML text of a vector drawable |
| * @return the viewport size of the drawable or null |
| */ |
| @Nullable |
| public static Point2D getViewportSize(@NotNull String drawable) { |
| KXmlParser parser = new KXmlParser(); |
| |
| try { |
| parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); |
| parser.setInput(CharSequences.getReader(drawable, true)); |
| // Skip to the first tag. |
| int token; |
| //noinspection StatementWithEmptyBody |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.START_TAG) { |
| } |
| if (parser.getEventType() != XmlPullParser.START_TAG || !"vector".equals(parser.getName()) || parser.getPrefix() != null) { |
| return null; // Not a vector drawable. |
| } |
| double viewportWidth = getDoubleAttributeValue(parser, ANDROID_URI, "viewportWidth", ""); |
| double viewportHeight = getDoubleAttributeValue(parser, ANDROID_URI, "viewportHeight", ""); |
| return new Point2D.Double(viewportWidth, viewportHeight); |
| } |
| catch (XmlPullParserException | IOException e) { |
| return null; // Ignore and return null. |
| } |
| } |
| |
| /** |
| * Returns size of the vector drawable in "dp", or null if the parameter is not a valid vector drawable, |
| * or the width and height are not specified in "dp". |
| * |
| * @param drawable XML text of a vector drawable |
| * @return the size of the drawable or null |
| */ |
| @Nullable |
| public static Dimension getSizeDp(@NotNull String drawable) { |
| KXmlParser parser = new KXmlParser(); |
| |
| try { |
| parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); |
| parser.setInput(CharSequences.getReader(drawable, true)); |
| // Skip to the first tag. |
| int token; |
| //noinspection StatementWithEmptyBody |
| while ((token = parser.nextToken()) != XmlPullParser.END_DOCUMENT && token != XmlPullParser.START_TAG) { |
| } |
| if (parser.getEventType() != XmlPullParser.START_TAG || !"vector".equals(parser.getName()) || parser.getPrefix() != null) { |
| return null; // Not a vector drawable. |
| } |
| String widthValue = parser.getAttributeValue(ANDROID_URI, "width"); |
| if (widthValue != null) { |
| String suffix = getSuffix(widthValue); |
| if (suffix.equals("dp")) { |
| double width = getDoubleAttributeValue(parser, ANDROID_URI, "width", suffix); |
| double height = getDoubleAttributeValue(parser, ANDROID_URI, "height", suffix); |
| return new Dimension(Math.round((float)width), Math.round((float)height)); |
| } |
| } |
| return null; |
| } |
| catch (XmlPullParserException | IOException e) { |
| return null; // Ignore and return null. |
| } |
| } |
| |
| private static String detectLineSeparator(@NotNull CharSequence str) { |
| LineSeparator separator = StringUtil.detectSeparators(str); |
| if (separator != null) { |
| return separator.getSeparatorString(); |
| } |
| return CodeStyle.getDefaultSettings().getLineSeparator(); |
| } |
| |
| @SuppressWarnings("SameParameterValue") |
| private static double getDoubleAttributeValue(@NotNull KXmlParser parser, @NotNull String namespaceUri, @NotNull String attributeName, |
| @NotNull String expectedSuffix) { |
| String value = parser.getAttributeValue(namespaceUri, attributeName); |
| return parseDoubleValue(value, expectedSuffix); |
| } |
| |
| private static double parseDoubleValue(String value, @NotNull String expectedSuffix) { |
| if (value == null || !value.endsWith(expectedSuffix)) { |
| return Double.NaN; |
| } |
| try { |
| return Double.parseDouble(value.substring(0, value.length() - expectedSuffix.length())); |
| } catch (NumberFormatException e) { |
| return Double.NaN; |
| } |
| } |
| |
| @NotNull |
| private static String getSuffix(@NotNull String value) { |
| int i = value.length(); |
| while (--i >= 0) { |
| if (Character.isDigit(value.charAt(i))) { |
| break; |
| } |
| } |
| ++i; |
| return value.substring(i); |
| } |
| |
| private static boolean isSignificantlyDifferentFromZero(double value) { |
| return Math.abs(value) >= 1.e-6; |
| } |
| |
| private static class Indenter { |
| private int myLine; |
| private int myColumn; |
| private int myOffset; |
| private @NotNull final CharSequence myText; |
| |
| Indenter(@NotNull CharSequence text) { |
| myText = text; |
| myLine = 1; |
| myColumn = 1; |
| } |
| |
| void copy(int fromLine, int fromColumn, int toLine, int toColumn, @NotNull String indent, @NotNull StringBuilder out) { |
| if (myLine != fromLine) { |
| if (myLine > fromLine) { |
| myLine = 1; |
| myColumn = 1; |
| myOffset = 0; |
| } |
| while (myLine < fromLine) { |
| char c = myText.charAt(myOffset); |
| if (c == '\n') { |
| myLine++; |
| myColumn = 1; |
| } else { |
| if (myLine != 1 || myColumn != 1 || c != '\uFEFF') { // Byte order mark doesn't occupy a column. |
| myColumn++; |
| } |
| } |
| myOffset++; |
| } |
| } |
| myOffset += fromColumn - myColumn; |
| myColumn = fromColumn; |
| while (myLine < toLine || myLine == toLine && myColumn < toColumn) { |
| char c = myText.charAt(myOffset); |
| if (c == '\n') { |
| myLine++; |
| myColumn = 1; |
| } else { |
| if (myLine != 1 || myColumn != 1 || c != '\uFEFF') { // Byte order mark doesn't occupy a column. |
| if (myColumn == 1 && |
| (c != '\r' || myOffset >= myText.length() || myText.charAt(myOffset + 1) != '\n')) { // Don't indent empty lines on Windows |
| out.append(indent); |
| } |
| myColumn++; |
| } |
| } |
| myOffset++; |
| out.append(c); |
| } |
| } |
| } |
| } |