blob: 99d4dae7ecbb2e19fd96a46ab311d2dbc1d2c445 [file] [log] [blame]
/*
* 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);
}
}
}
}