This commit is contained in:
NianChen
2023-04-13 18:06:05 +08:00
commit e5873ae6fe
4063 changed files with 267552 additions and 0 deletions

View File

@ -0,0 +1,16 @@
# Generator
This module generates codes of fluent-icons from svg.
Thanks for the tutorial: https://github.com/DevSrSouza/svg-to-compose
The source code of the tool that converts SVG to XML is from [Android Studio](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-master-dev/sdk-common/src/main/java/com/android/ide/common/vectordrawable)
The source code of the tool that converts XML to Kotlin code is from [Jetpack Compose](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material/material/icons/generator/)
1. Save Icons HTML
1. Access [Fluent UI Catalog](https://react.fluentui.dev/iframe.html?viewMode=docs&id=concepts-developer-icons-icons-catalog--page)
2. Save page as html to `icons-catalog.html`
3. Run `ExtractSvgFromCatalogKt`
2. Run `ConvertToXmlKt`
3. Run `ConvertToCodeKt`

View File

@ -0,0 +1,22 @@
plugins {
kotlin("multiplatform")
}
kotlin {
jvm {
withJava()
}
sourceSets {
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-client-java:2.2.1")
implementation("org.jsoup:jsoup:1.15.3")
implementation("com.google.guava:guava:31.1-jre")
implementation("com.android.tools:common:27.2.0-alpha16")
implementation("com.android.tools:sdk-common:27.2.0-alpha16")
implementation("com.squareup:kotlinpoet:1.12.0")
}
}
val jvmTest by getting
}
}

View File

@ -0,0 +1,276 @@
/*
* 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 static java.lang.Math.atan;
import static java.lang.Math.cos;
import static java.lang.Math.sin;
import static java.lang.Math.sqrt;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.util.logging.Level;
import java.util.logging.Logger;
class EllipseSolver {
// Final results:
private float mMajorAxis;
private float mMinorAxis;
private float mRotationDegree;
private boolean mDirectionChanged;
/**
* Constructs the solver with all necessary parameters, and all the output values will
* be ready after this constructor is called.
* <p>
* Note that all the x y values are in absolute coordinates, such that we can apply
* the transform directly.
*/
public EllipseSolver(AffineTransform totalTransform, float currentX, float currentY,
float rx, float ry, float xAxisRotation, float largeArcFlag, float sweepFlag,
float destX, float destY) {
if (rx == 0 || ry == 0) {
// From https://www.w3.org/TR/SVG11/implnote.html#ArcOutOfRangeParameters:
// "If rx = 0 or ry = 0 then this arc is treated as a straight line segment
// (a "lineto") joining the endpoints."
return;
}
boolean largeArc = largeArcFlag != 0;
boolean sweep = sweepFlag != 0;
// Compute the cx and cy first.
Point2D.Double originalCenter = computeOriginalCenter(currentX, currentY, rx, ry,
xAxisRotation, largeArc, sweep, destX, destY);
// Compute 3 points from original ellipse.
Point2D.Double majorAxisPoint = new Point2D.Double(rx, 0);
Point2D.Double minorAxisPoint = new Point2D.Double(0, ry);
majorAxisPoint = rotatePoint2D(majorAxisPoint, xAxisRotation);
minorAxisPoint = rotatePoint2D(minorAxisPoint, xAxisRotation);
majorAxisPoint.x += originalCenter.x;
majorAxisPoint.y += originalCenter.y;
minorAxisPoint.x += originalCenter.x;
minorAxisPoint.y += originalCenter.y;
double middleRadians = Math.PI / 4; // This number can be anything between 0 and PI/2.
double middleR = rx * ry / Math.hypot(ry * cos(middleRadians), rx * sin(middleRadians));
Point2D.Double middlePoint =
new Point2D.Double(middleR * cos(middleRadians),middleR * sin(middleRadians));
middlePoint = rotatePoint2D(middlePoint, xAxisRotation);
middlePoint.x += originalCenter.x;
middlePoint.y += originalCenter.y;
// Transform 3 points and center point into destination.
Point2D.Double mDstMiddlePoint =
(Point2D.Double) totalTransform.transform(middlePoint, null);
Point2D.Double mDstMajorAxisPoint =
(Point2D.Double) totalTransform.transform(majorAxisPoint, null);
Point2D.Double mDstMinorAxisPoint =
(Point2D.Double) totalTransform.transform(minorAxisPoint, null);
Point2D dstCenter = totalTransform.transform(originalCenter, null);
double dstCenterX = dstCenter.getX();
double dstCenterY = dstCenter.getY();
// Compute the relative 3 points:
double relativeDstMiddleX = mDstMiddlePoint.x - dstCenterX;
double relativeDstMiddleY = mDstMiddlePoint.y - dstCenterY;
double relativeDstMajorAxisPointX = mDstMajorAxisPoint.x - dstCenterX;
double relativeDstMajorAxisPointY = mDstMajorAxisPoint.y - dstCenterY;
double relativeDstMinorAxisPointX = mDstMinorAxisPoint.x - dstCenterX;
double relativeDstMinorAxisPointY = mDstMinorAxisPoint.y - dstCenterY;
// Check if the direction has changed.
mDirectionChanged = computeDirectionChange(middlePoint, majorAxisPoint, minorAxisPoint,
mDstMiddlePoint, mDstMajorAxisPoint, mDstMinorAxisPoint);
// From 3 dest points, recompute the a, b and theta.
if (computeABThetaFromControlPoints(relativeDstMiddleX, relativeDstMiddleY,
relativeDstMajorAxisPointX, relativeDstMajorAxisPointY,
relativeDstMinorAxisPointX, relativeDstMinorAxisPointY)) {
getLog().log(Level.WARNING, "Early return in the ellipse transformation computation!");
}
}
private static Logger getLog() {
return Logger.getLogger(EllipseSolver.class.getSimpleName());
}
/**
* After a random transformation, the controls points may change its direction, left handed <->
* right handed. In this case, we better flip the flag for the ArcTo command.
*
* Here, we use the cross product to figure out the direction of the 3 control points for the
* src and dst ellipse.
*/
private static boolean computeDirectionChange(Point2D.Double middlePoint,
Point2D.Double majorAxisPoint, Point2D.Double minorAxisPoint,
Point2D.Double dstMiddlePoint, Point2D.Double dstMajorAxisPoint,
Point2D.Double dstMinorAxisPoint) {
// Compute both cross product, then compare the sign.
double srcCrossProduct = getCrossProduct(middlePoint, majorAxisPoint, minorAxisPoint);
double dstCrossProduct = getCrossProduct(dstMiddlePoint, dstMajorAxisPoint,
dstMinorAxisPoint);
return srcCrossProduct * dstCrossProduct < 0;
}
private static double getCrossProduct(Point2D.Double middlePoint, Point2D.Double majorAxisPoint,
Point2D.Double minorAxisPoint) {
double majorMinusMiddleX = majorAxisPoint.x - middlePoint.x;
double majorMinusMiddleY = majorAxisPoint.y - middlePoint.y;
double minorMinusMiddleX = minorAxisPoint.x - middlePoint.x;
double minorMinusMiddleY = minorAxisPoint.y - middlePoint.y;
return majorMinusMiddleX * minorMinusMiddleY - majorMinusMiddleY * minorMinusMiddleX;
}
/**
* Returns true if there is an error, either due to the ellipse not being specified
* correctly or some computation error. This error is ignorable, but the output ellipse
* will not be correct.
*/
private boolean computeABThetaFromControlPoints(double relMiddleX, double relMiddleY,
double relativeMajorAxisPointX, double relativeMajorAxisPointY,
double relativeMinorAxisPointX, double relativeMinorAxisPointY) {
double m11 = relMiddleX * relMiddleX;
double m12 = relMiddleX * relMiddleY;
double m13 = relMiddleY * relMiddleY;
double m21 = relativeMajorAxisPointX * relativeMajorAxisPointX;
double m22 = relativeMajorAxisPointX * relativeMajorAxisPointY;
double m23 = relativeMajorAxisPointY * relativeMajorAxisPointY;
double m31 = relativeMinorAxisPointX * relativeMinorAxisPointX;
double m32 = relativeMinorAxisPointX * relativeMinorAxisPointY;
double m33 = relativeMinorAxisPointY * relativeMinorAxisPointY;
double det = -(m13 * m22 * m31 - m12 * m23 * m31 - m13 * m21 * m32
+ m11 * m23 * m32 + m12 * m21 * m33 - m11 * m22 * m33);
if (det == 0) {
return true;
}
double A = (-m13 * m22 + m12 * m23 + m13 * m32 - m23 * m32 - m12 * m33 + m22 * m33)
/ det;
double B = (m13 * m21 - m11 * m23 - m13 * m31 + m23 * m31 + m11 * m33 - m21 * m33) / det;
double C = (m12 * m21 - m11 * m22 - m12 * m31 + m22 * m31 + m11 * m32 - m21 * m32)
/ (-det);
// Now we know A = cos(t)^2 / a^2 + sin(t)^2 / b^2
// B = -2 cos(t) sin(t) (1/a^2 - 1/b^2)
// C = sin(t)^2 / a^2 + cos(t)^2 / b^2
// Solve it, we got
// 2*t = arctan ( B / (A - C));
if (A - C == 0) {
// We know that a == b now.
mMinorAxis = (float) Math.hypot(relativeMajorAxisPointX, relativeMajorAxisPointY);
mMajorAxis = mMinorAxis;
mRotationDegree = 0;
return false;
}
double doubleThetaInRadians = atan(B / (A - C));
double thetaInRadians = doubleThetaInRadians / 2;
if (sin(doubleThetaInRadians) == 0) {
mMinorAxis = (float) sqrt(1 / C);
mMajorAxis = (float) sqrt(1 / A);
mRotationDegree = 0;
// This is a valid answer, so return false.
return false;
}
double bSqInv = (A + C + B / sin(doubleThetaInRadians)) / 2;
double aSqInv = A + C - bSqInv;
if (bSqInv == 0 || aSqInv == 0) {
return true;
}
mMinorAxis = (float) sqrt(1 / bSqInv);
mMajorAxis = (float) sqrt(1 / aSqInv);
mRotationDegree = (float) Math.toDegrees(Math.PI / 2 + thetaInRadians);
return false;
}
private static Point2D.Double computeOriginalCenter(float x1, float y1, float rx, float ry,
float phi, boolean largeArc, boolean sweep, float x2, float y2) {
double cosPhi = cos(phi);
double sinPhi = sin(phi);
double xDelta = (x1 - x2) / 2;
double yDelta = (y1 - y2) / 2;
double tempX1 = cosPhi * xDelta + sinPhi * yDelta;
double tempY1 = -sinPhi * xDelta + cosPhi * yDelta;
double rxSq = rx * rx;
double rySq = ry * ry;
double tempX1Sq = tempX1 * tempX1;
double tempY1Sq = tempY1 * tempY1;
double tempCenterFactor = rxSq * rySq - rxSq * tempY1Sq - rySq * tempX1Sq;
tempCenterFactor /= rxSq * tempY1Sq + rySq * tempX1Sq;
if (tempCenterFactor < 0) {
tempCenterFactor = 0;
}
tempCenterFactor = sqrt(tempCenterFactor);
if (largeArc == sweep) {
tempCenterFactor = -tempCenterFactor;
}
double tempCx = tempCenterFactor * rx * tempY1 / ry;
double tempCy = -tempCenterFactor * ry * tempX1 / rx;
double xCenter = (x1 + x2) / 2;
double yCenter = (y1 + y2) / 2;
return new Point2D.Double(cosPhi * tempCx - sinPhi * tempCy + xCenter,
sinPhi * tempCx + cosPhi * tempCy + yCenter);
}
public float getMajorAxis() {
return mMajorAxis;
}
public float getMinorAxis() {
return mMinorAxis;
}
public float getRotationDegree() {
return mRotationDegree;
}
public boolean getDirectionChanged() {
return mDirectionChanged;
}
/**
* Rotates a point by the given angle.
*
* @param inPoint the point to rotate
* @param radians the rotation angle in radians
* @return the rotated point
*/
private static Point2D.Double rotatePoint2D(Point2D.Double inPoint, double radians) {
double cos = cos(radians);
double sin = sin(radians);
return new Point2D.Double(inPoint.x * cos - inPoint.y * sin,
inPoint.x * sin + inPoint.y * cos);
}
}

View File

@ -0,0 +1,49 @@
/*
* Copyright (C) 2017 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;
/** Represents an SVG gradient stop or Android's GradientColorItem. */
public class GradientStop {
private String color;
private String offset;
private String opacity = "";
GradientStop(@NonNull String color, @NonNull String offset) {
this.color = color;
this.offset = offset;
}
@NonNull
String getColor() {
return color;
}
@NonNull
String getOffset() {
return offset;
}
@NonNull
String getOpacity() {
return opacity;
}
protected void setOpacity(@NonNull String opacity) {
this.opacity = opacity;
}
}

View File

@ -0,0 +1,27 @@
/*
* 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.ide.common.vectordrawable
import com.android.ide.common.blame.SourcePosition
/**
* Runtime exception that a resource reference incorrectly referenced from vector drawables
* causing PNG generation to fail.
*/
class IllegalVectorDrawableResourceRefException(
val value: String, val sourcePosition: SourcePosition, message: String?)
: RuntimeException(message)

View File

@ -0,0 +1,236 @@
/*
* 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 static com.android.utils.XmlUtils.formatFloatValue;
/** Builds a string for SVG file's path data. */
public class PathBuilder {
private StringBuilder mPathData = new StringBuilder();
private static char encodeBoolean(boolean flag) {
return flag ? '1' : '0';
}
public PathBuilder absoluteMoveTo(double x, double y) {
mPathData.append('M').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder relativeMoveTo(double x, double y) {
mPathData.append('m').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteLineTo(double x, double y) {
mPathData.append('L').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder relativeLineTo(double x, double y) {
mPathData.append('l').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteVerticalTo(double v) {
mPathData.append('V').append(formatFloatValue(v));
return this;
}
public PathBuilder relativeVerticalTo(double v) {
mPathData.append('v').append(formatFloatValue(v));
return this;
}
public PathBuilder absoluteHorizontalTo(double h) {
mPathData.append('H').append(formatFloatValue(h));
return this;
}
public PathBuilder relativeHorizontalTo(double h) {
mPathData.append('h').append(formatFloatValue(h));
return this;
}
public PathBuilder absoluteCurveTo(
double cp1x, double cp1y, double cp2x, double cp2y, double x, double y) {
mPathData
.append('C')
.append(formatFloatValue(cp1x))
.append(',')
.append(formatFloatValue(cp1y))
.append(',')
.append(formatFloatValue(cp2x))
.append(',')
.append(formatFloatValue(cp2y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder relativeCurveTo(
double cp1x, double cp1y, double cp2x, double cp2y, double x, double y) {
mPathData
.append('c')
.append(formatFloatValue(cp1x))
.append(',')
.append(formatFloatValue(cp1y))
.append(',')
.append(formatFloatValue(cp2x))
.append(',')
.append(formatFloatValue(cp2y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteSmoothCurveTo(double cp2x, double cp2y, double x, double y) {
mPathData
.append('S')
.append(formatFloatValue(cp2x))
.append(',')
.append(formatFloatValue(cp2y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder relativeSmoothCurveTo(double cp2x, double cp2y, double x, double y) {
mPathData
.append('s')
.append(formatFloatValue(cp2x))
.append(',')
.append(formatFloatValue(cp2y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteQuadraticCurveTo(double cp1x, double cp1y, double x, double y) {
mPathData
.append('Q')
.append(formatFloatValue(cp1x))
.append(',')
.append(formatFloatValue(cp1y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder relativeQuadraticCurveTo(double cp1x, double cp1y, double x, double y) {
mPathData
.append('q')
.append(formatFloatValue(cp1x))
.append(',')
.append(formatFloatValue(cp1y))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteSmoothQuadraticCurveTo(double x, double y) {
mPathData.append('T').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder relativeSmoothQuadraticCurveTo(double x, double y) {
mPathData.append('t').append(formatFloatValue(x)).append(',').append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteArcTo(
double rx,
double ry,
boolean rotation,
boolean largeArc,
boolean sweep,
double x,
double y) {
mPathData
.append('A')
.append(formatFloatValue(rx))
.append(',')
.append(formatFloatValue(ry))
.append(',')
.append(encodeBoolean(rotation))
.append(',')
.append(encodeBoolean(largeArc))
.append(',')
.append(encodeBoolean(sweep))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder relativeArcTo(
double rx,
double ry,
boolean rotation,
boolean largeArc,
boolean sweep,
double x,
double y) {
mPathData
.append('a')
.append(formatFloatValue(rx))
.append(',')
.append(formatFloatValue(ry))
.append(',')
.append(encodeBoolean(rotation))
.append(',')
.append(encodeBoolean(largeArc))
.append(',')
.append(encodeBoolean(sweep))
.append(',')
.append(formatFloatValue(x))
.append(',')
.append(formatFloatValue(y));
return this;
}
public PathBuilder absoluteClose() {
mPathData.append('Z');
return this;
}
public PathBuilder relativeClose() {
mPathData.append('z');
return this;
}
/** Returns true if the PathBuilder doesn't contain any data. */
public boolean isEmpty() {
return mPathData.length() == 0;
}
public String toString() {
return mPathData.toString();
}
}

View File

@ -0,0 +1,202 @@
/*
* 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 java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Utility functions for parsing path information. The implementation details should be the same as
* the PathParser in Android framework.
*
* <p>See https://www.w3.org/TR/SVG/paths.html#PathDataBNF for the pathData syntax.
*/
public class PathParser {
private static final float[] EMPTY_FLOAT_ARRAY = new float[0];
private static class ExtractFloatResult {
/** The end position of the parameter. */
private int mEndPosition;
/** Whether there is an explicit separator after the end position or not. */
private boolean mExplicitSeparator;
}
// Do not instantiate.
private PathParser() {}
/**
* Determines the end position of a command parameter.
*
* @param s the string to search
* @param start the position to start searching
* @param flagMode indicates Boolean flag syntax; a Boolean flag is either "0" or "1" and
* doesn't have to be followed by a separator
* @param result the result of the extraction
*/
private static void extract(
@NonNull String s, int start, boolean flagMode, @NonNull ExtractFloatResult result) {
boolean foundSeparator = false;
result.mExplicitSeparator = false;
boolean secondDot = false;
boolean isExponential = false;
// Looking for ' ', ',', '.' or '-' from the start.
int currentIndex = start;
for (; currentIndex < s.length(); currentIndex++) {
boolean isPrevExponential = isExponential;
isExponential = false;
char currentChar = s.charAt(currentIndex);
switch (currentChar) {
case ' ':
case ',':
foundSeparator = true;
result.mExplicitSeparator = true;
break;
case '-':
// The negative sign following a 'e' or 'E' is not an implicit separator.
if (currentIndex != start && !isPrevExponential) {
foundSeparator = true;
}
break;
case '.':
if (secondDot) {
// Second dot is an implicit separator.
foundSeparator = true;
} else {
secondDot = true;
}
break;
case 'e':
case 'E':
isExponential = true;
break;
}
if (foundSeparator || flagMode && currentIndex > start) {
break;
}
}
// When there is nothing found, then we put the end position to the end of the string.
result.mEndPosition = currentIndex;
}
/**
* Parses the floats in the string this is an optimized version of parseFloat(s.split(",|\\s"));
*
* @param s the string containing a command and list of floats
* @param parseMode
* @return array of floats
*/
@NonNull
private static float[] getFloats(@NonNull String s, @NonNull ParseMode parseMode) {
char command = s.charAt(0);
if (command == 'z' || command == 'Z') {
return EMPTY_FLOAT_ARRAY;
}
try {
boolean arcCommand = command == 'a' || command == 'A';
float[] results = new float[s.length()];
int count = 0;
int startPosition = 1;
int endPosition;
ExtractFloatResult result = new ExtractFloatResult();
int totalLength = s.length();
// The startPosition should always be the first character of the current number, and
// endPosition is the character after the current number.
while (startPosition < totalLength) {
// In ANDROID parse mode we treat flags as regular floats for compatibility with
// old vector drawables that may have pathData not conforming to
// https://www.w3.org/TR/SVG/paths.html#PathDataBNF. In such a case flags may be
// represented by "1.0" or "0.0" (b/146520216).
boolean flagMode =
parseMode == ParseMode.SVG
&& arcCommand
&& (count % 7 == 3 || count % 7 == 4);
extract(s, startPosition, flagMode, result);
endPosition = result.mEndPosition;
if (startPosition < endPosition) {
results[count++] = Float.parseFloat(s.substring(startPosition, endPosition));
}
if (result.mExplicitSeparator) {
startPosition = endPosition + 1;
} else {
startPosition = endPosition;
}
}
return Arrays.copyOfRange(results, 0, count);
} catch (NumberFormatException e) {
throw new RuntimeException("Error in parsing \"" + s + "\"", e);
}
}
private static void addNode(@NonNull List<VdPath.Node> list, char cmd, @NonNull float[] val) {
list.add(new VdPath.Node(cmd, val));
}
private static int nextStart(@NonNull String s, int end) {
while (end < s.length()) {
char c = s.charAt(end);
// Note that 'e' or 'E' are not valid path commands, but could be used for floating
// point numbers' scientific notation. Therefore, when searching for next command, we
// should ignore 'e' and 'E'.
if ('A' <= c && c <= 'Z' && c != 'E' || 'a' <= c && c <= 'z' && c != 'e') {
return end;
}
end++;
}
return end;
}
@NonNull
public static VdPath.Node[] parsePath(@NonNull String value, @NonNull ParseMode mode) {
value = value.trim();
List<VdPath.Node> list = new ArrayList<>();
int start = 0;
int end = 1;
while (end < value.length()) {
end = nextStart(value, end);
String s = value.substring(start, end);
char currentCommand = s.charAt(0);
float[] val = getFloats(s, mode);
if (start == 0) {
// For the starting command, special handling: add M 0 0 if there is none.
// This is good for transformation.
if (currentCommand != 'M' && currentCommand != 'm') {
addNode(list, 'M', new float[2]);
}
}
addNode(list, currentCommand, val);
start = end;
end++;
}
if (end - start == 1 && start < value.length()) {
addNode(list, value.charAt(start), EMPTY_FLOAT_ARRAY);
}
return list.toArray(new VdPath.Node[0]);
}
public enum ParseMode {
SVG,
ANDROID
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2017 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;
/**
* Indicates that the input vector drawable XML file included references to other Android resources.
*/
public class ResourcesNotSupportedException extends RuntimeException {
private final String name;
private final String value;
public ResourcesNotSupportedException(@NonNull String name, @NonNull String value) {
super(String.format("Cannot process attribute %1$s=\"%2$s\"", name, value));
this.name = name;
this.value = value;
}
public String getName() {
return name;
}
public String getValue() {
return value;
}
}

View File

@ -0,0 +1,201 @@
/*
* Copyright (C) 2017 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 static com.android.SdkConstants.TAG_CLIP_PATH;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_CLIP_RULE;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_MASK;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.google.common.collect.Iterables;
import java.awt.geom.AffineTransform;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.w3c.dom.Element;
/**
* Represents a SVG group element that contains a clip-path. SvgClipPathNode's mChildren will
* contain the actual path data of the clip-path. The path of the clip will be constructed in
* {@link #writeXml} by concatenating mChildren's paths. mAffectedNodes contains any group or leaf
* nodes that are clipped by the path.
*/
class SvgClipPathNode extends SvgGroupNode {
private final ArrayList<SvgNode> mAffectedNodes = new ArrayList<>();
SvgClipPathNode(@NonNull SvgTree svgTree, @NonNull Element element, @Nullable String name) {
super(svgTree, element, name);
}
@Override
@NonNull
public SvgClipPathNode deepCopy() {
SvgClipPathNode newInstance = new SvgClipPathNode(getTree(), mDocumentElement, mName);
newInstance.copyFrom(this);
return newInstance;
}
protected void copyFrom(@NonNull SvgClipPathNode from) {
super.copyFrom(from);
for (SvgNode node : from.mAffectedNodes) {
addAffectedNode(node);
}
}
@Override
public void addChild(@NonNull SvgNode child) {
// Pass the presentation map down to the children, who can override the attributes.
mChildren.add(child);
// The child has its own attributes map. But the parents can still fill some attributes
// if they don't exists
child.fillEmptyAttributes(mVdAttributesMap);
}
public void addAffectedNode(@NonNull SvgNode child) {
mAffectedNodes.add(child);
child.fillEmptyAttributes(mVdAttributesMap);
}
@Override
public void flatten(@NonNull AffineTransform transform) {
for (SvgNode n : mChildren) {
mStackedTransform.setTransform(transform);
mStackedTransform.concatenate(mLocalTransform);
n.flatten(mStackedTransform);
}
mStackedTransform.setTransform(transform);
for (SvgNode n : mAffectedNodes) {
n.flatten(mStackedTransform); // mLocalTransform does not apply to mAffectedNodes.
}
mStackedTransform.concatenate(mLocalTransform);
if (mVdAttributesMap.containsKey(Svg2Vector.SVG_STROKE_WIDTH)
&& ((mStackedTransform.getType() & AffineTransform.TYPE_MASK_SCALE) != 0)) {
logWarning("Scaling of the stroke width is ignored");
}
}
@Override
public void validate() {
super.validate();
if (mDocumentElement.getTagName().equals(SVG_MASK) && !isWhiteFill()) {
// A mask that is not solid white creates a transparency effect that cannot be
// reproduced by a clip-path.
logError("Semitransparent mask cannot be represented by a vector drawable");
}
}
private boolean isWhiteFill() {
String fillColor = mVdAttributesMap.get("fill");
if (fillColor == null) {
return false;
}
fillColor = colorSvg2Vd(fillColor, "#000");
if (fillColor == null) {
return false;
}
return VdUtil.parseColorValue(fillColor) == 0xFFFFFFFF;
}
@Override
public void transformIfNeeded(@NonNull AffineTransform rootTransform) {
for (SvgNode p : Iterables.concat(mChildren, mAffectedNodes)) {
p.transformIfNeeded(rootTransform);
}
}
@Override
public void writeXml(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
writer.write(indent);
writer.write("<group>");
writer.write(System.lineSeparator());
String incrementedIndent = indent + INDENT_UNIT;
Map<ClipRule, List<String>> clipPaths = new EnumMap<>(ClipRule.class);
Visitor clipPathCollector = node -> {
if (node instanceof SvgLeafNode) {
String pathData = ((SvgLeafNode) node).getPathData();
if (pathData != null && !pathData.isEmpty()) {
ClipRule clipRule =
"evenOdd".equals(node.mVdAttributesMap.get(SVG_CLIP_RULE))
? ClipRule.EVEN_ODD
: ClipRule.NON_ZERO;
List<String> paths =
clipPaths.computeIfAbsent(clipRule, key -> new ArrayList<>());
paths.add(pathData);
}
}
return VisitResult.CONTINUE;
};
for (SvgNode node : mChildren) {
node.accept(clipPathCollector);
}
for (Map.Entry<ClipRule, List<String>> entry : clipPaths.entrySet()) {
ClipRule clipRule = entry.getKey();
List<String> pathData = entry.getValue();
writer.write(incrementedIndent);
writer.write('<');
writer.write(TAG_CLIP_PATH);
writer.write(System.lineSeparator());
writer.write(incrementedIndent);
writer.write(INDENT_UNIT);
writer.write(INDENT_UNIT);
writer.write("android:pathData=\"");
for (int i = 0; i < pathData.size(); i++) {
String path = pathData.get(i);
if (i > 0 && !path.startsWith("M")) {
// Reset the current position to the origin of the coordinate system.
writer.write("M 0,0");
}
writer.write(path);
}
writer.write('"');
if (clipRule == ClipRule.EVEN_ODD) {
writer.write(System.lineSeparator());
writer.write(incrementedIndent);
writer.write(INDENT_UNIT);
writer.write(INDENT_UNIT);
writer.write("android:fillType=\"evenOdd\"");
}
writer.write("/>");
writer.write(System.lineSeparator());
}
for (SvgNode node : mAffectedNodes) {
node.writeXml(writer, incrementedIndent);
}
writer.write(indent);
writer.write("</group>");
writer.write(System.lineSeparator());
}
/**
* Concatenates the affected nodes transformations to the clipPathNode's so it is properly
* transformed.
*/
public void setClipPathNodeAttributes() {
for (SvgNode n : mAffectedNodes) {
mLocalTransform.concatenate(n.mLocalTransform);
}
}
}

View File

@ -0,0 +1,257 @@
/*
* Copyright (C) 2018 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.android.annotations.Nullable;
import com.google.common.collect.ImmutableMap;
import java.util.Locale;
/** Methods for converting SVG color values to vector drawable format. */
public class SvgColor {
/** Color table from https://www.w3.org/TR/SVG11/types.html#ColorKeywords. */
private static final ImmutableMap<String, String> colorMap =
ImmutableMap.<String, String>builder()
.put("aliceblue", "#f0f8ff")
.put("antiquewhite", "#faebd7")
.put("aqua", "#00ffff")
.put("aquamarine", "#7fffd4")
.put("azure", "#f0ffff")
.put("beige", "#f5f5dc")
.put("bisque", "#ffe4c4")
.put("black", "#000000")
.put("blanchedalmond", "#ffebcd")
.put("blue", "#0000ff")
.put("blueviolet", "#8a2be2")
.put("brown", "#a52a2a")
.put("burlywood", "#deb887")
.put("cadetblue", "#5f9ea0")
.put("chartreuse", "#7fff00")
.put("chocolate", "#d2691e")
.put("coral", "#ff7f50")
.put("cornflowerblue", "#6495ed")
.put("cornsilk", "#fff8dc")
.put("crimson", "#dc143c")
.put("cyan", "#00ffff")
.put("darkblue", "#00008b")
.put("darkcyan", "#008b8b")
.put("darkgoldenrod", "#b8860b")
.put("darkgray", "#a9a9a9")
.put("darkgrey", "#a9a9a9")
.put("darkgreen", "#006400")
.put("darkkhaki", "#bdb76b")
.put("darkmagenta", "#8b008b")
.put("darkolivegreen", "#556b2f")
.put("darkorange", "#ff8c00")
.put("darkorchid", "#9932cc")
.put("darkred", "#8b0000")
.put("darksalmon", "#e9967a")
.put("darkseagreen", "#8fbc8f")
.put("darkslateblue", "#483d8b")
.put("darkslategray", "#2f4f4f")
.put("darkslategrey", "#2f4f4f")
.put("darkturquoise", "#00ced1")
.put("darkviolet", "#9400d3")
.put("deeppink", "#ff1493")
.put("deepskyblue", "#00bfff")
.put("dimgray", "#696969")
.put("dimgrey", "#696969")
.put("dodgerblue", "#1e90ff")
.put("firebrick", "#b22222")
.put("floralwhite", "#fffaf0")
.put("forestgreen", "#228b22")
.put("fuchsia", "#ff00ff")
.put("gainsboro", "#dcdcdc")
.put("ghostwhite", "#f8f8ff")
.put("gold", "#ffd700")
.put("goldenrod", "#daa520")
.put("gray", "#808080")
.put("grey", "#808080")
.put("green", "#008000")
.put("greenyellow", "#adff2f")
.put("honeydew", "#f0fff0")
.put("hotpink", "#ff69b4")
.put("indianred", "#cd5c5c")
.put("indigo", "#4b0082")
.put("ivory", "#fffff0")
.put("khaki", "#f0e68c")
.put("lavender", "#e6e6fa")
.put("lavenderblush", "#fff0f5")
.put("lawngreen", "#7cfc00")
.put("lemonchiffon", "#fffacd")
.put("lightblue", "#add8e6")
.put("lightcoral", "#f08080")
.put("lightcyan", "#e0ffff")
.put("lightgoldenrodyellow", "#fafad2")
.put("lightgray", "#d3d3d3")
.put("lightgrey", "#d3d3d3")
.put("lightgreen", "#90ee90")
.put("lightpink", "#ffb6c1")
.put("lightsalmon", "#ffa07a")
.put("lightseagreen", "#20b2aa")
.put("lightskyblue", "#87cefa")
.put("lightslategray", "#778899")
.put("lightslategrey", "#778899")
.put("lightsteelblue", "#b0c4de")
.put("lightyellow", "#ffffe0")
.put("lime", "#00ff00")
.put("limegreen", "#32cd32")
.put("linen", "#faf0e6")
.put("magenta", "#ff00ff")
.put("maroon", "#800000")
.put("mediumaquamarine", "#66cdaa")
.put("mediumblue", "#0000cd")
.put("mediumorchid", "#ba55d3")
.put("mediumpurple", "#9370db")
.put("mediumseagreen", "#3cb371")
.put("mediumslateblue", "#7b68ee")
.put("mediumspringgreen", "#00fa9a")
.put("mediumturquoise", "#48d1cc")
.put("mediumvioletred", "#c71585")
.put("midnightblue", "#191970")
.put("mintcream", "#f5fffa")
.put("mistyrose", "#ffe4e1")
.put("moccasin", "#ffe4b5")
.put("navajowhite", "#ffdead")
.put("navy", "#000080")
.put("oldlace", "#fdf5e6")
.put("olive", "#808000")
.put("olivedrab", "#6b8e23")
.put("orange", "#ffa500")
.put("orangered", "#ff4500")
.put("orchid", "#da70d6")
.put("palegoldenrod", "#eee8aa")
.put("palegreen", "#98fb98")
.put("paleturquoise", "#afeeee")
.put("palevioletred", "#db7093")
.put("papayawhip", "#ffefd5")
.put("peachpuff", "#ffdab9")
.put("peru", "#cd853f")
.put("pink", "#ffc0cb")
.put("plum", "#dda0dd")
.put("powderblue", "#b0e0e6")
.put("purple", "#800080")
.put("rebeccapurple", "#663399")
.put("red", "#ff0000")
.put("rosybrown", "#bc8f8f")
.put("royalblue", "#4169e1")
.put("saddlebrown", "#8b4513")
.put("salmon", "#fa8072")
.put("sandybrown", "#f4a460")
.put("seagreen", "#2e8b57")
.put("seashell", "#fff5ee")
.put("sienna", "#a0522d")
.put("silver", "#c0c0c0")
.put("skyblue", "#87ceeb")
.put("slateblue", "#6a5acd")
.put("slategray", "#708090")
.put("slategrey", "#708090")
.put("snow", "#fffafa")
.put("springgreen", "#00ff7f")
.put("steelblue", "#4682b4")
.put("tan", "#d2b48c")
.put("teal", "#008080")
.put("thistle", "#d8bfd8")
.put("tomato", "#ff6347")
.put("turquoise", "#40e0d0")
.put("violet", "#ee82ee")
.put("wheat", "#f5deb3")
.put("white", "#ffffff")
.put("whitesmoke", "#f5f5f5")
.put("yellow", "#ffff00")
.put("yellowgreen", "#9acd32")
.build();
/** Do not instantiate. All methods are static. */
private SvgColor() {}
/**
* Converts an SVG color value to "#RRGGBB" or "#AARRGGBB" format used by vector drawables.
* The input color value can be "none" and RGB value, e.g. "rgb(255, 0, 0)",
* "rgba(255, 0, 0, 127)", or a color name defined in
* https://www.w3.org/TR/SVG11/types.html#ColorKeywords.
*
* @param svgColorValue the SVG color value to convert
* @return the converted value, or null if the given value cannot be interpreted as color
* @throws IllegalArgumentException if the supplied SVG color value has invalid or unsupported
* format
*/
@Nullable
protected static String colorSvg2Vd(@NonNull String svgColorValue) {
String color = svgColorValue.trim();
if (color.startsWith("#")) {
return color;
}
if ("none".equals(color)) {
return "#00000000";
}
if (color.startsWith("rgb(") && color.endsWith(")")) {
String rgb = color.substring(4, color.length() - 1);
String[] numbers = rgb.split(",");
if (numbers.length != 3) {
throw new IllegalArgumentException(svgColorValue);
}
StringBuilder builder = new StringBuilder(7);
builder.append("#");
for (int i = 0; i < 3; i++) {
int component = getColorComponent(numbers[i].trim(), svgColorValue);
builder.append(String.format("%02X", component));
}
assert builder.length() == 7;
return builder.toString();
}
if (color.startsWith("rgba(") && color.endsWith(")")) {
String rgb = color.substring(5, color.length() - 1);
String[] numbers = rgb.split(",");
if (numbers.length != 4) {
throw new IllegalArgumentException(svgColorValue);
}
StringBuilder builder = new StringBuilder(9);
builder.append("#");
for (int i = 0; i < 4; i++) {
int component = getColorComponent(numbers[(i + 3) % 4].trim(), svgColorValue);
builder.append(String.format("%02X", component));
}
assert builder.length() == 9;
return builder.toString();
}
return colorMap.get(color.toLowerCase(Locale.ENGLISH));
}
private static int getColorComponent(
@NonNull String colorComponent, @NonNull String svgColorValue) {
try {
if (colorComponent.endsWith("%")) {
float value =
Float.parseFloat(colorComponent.substring(0, colorComponent.length() - 1));
return clampColor(Math.round(value * 255.f / 100.f));
}
return clampColor(Integer.parseInt(colorComponent));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(svgColorValue);
}
}
private static int clampColor(int val) {
return Math.max(0, Math.min(255, val));
}
}

View File

@ -0,0 +1,428 @@
/*
* Copyright (C) 2017 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 static com.android.ide.common.vectordrawable.VdUtil.parseColorValue;
import static com.android.utils.DecimalUtils.trimInsignificantZeros;
import static com.android.utils.XmlUtils.formatFloatValue;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.vectordrawable.PathParser.ParseMode;
import com.google.common.collect.ImmutableMap;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.Element;
/** Represents a SVG gradient that is referenced by a SvgLeafNode. */
class SvgGradientNode extends SvgNode {
private static final Logger logger = Logger.getLogger(SvgGroupNode.class.getSimpleName());
private final ArrayList<GradientStop> myGradientStops = new ArrayList<>();
private SvgLeafNode mSvgLeafNode;
// Bounding box of mSvgLeafNode.
private Rectangle2D boundingBox;
private GradientUsage mGradientUsage;
private static class GradientCoordResult {
private final double mValue;
// When the gradientUnits is set to "userSpaceOnUse", we usually use the coordinate values
// as it is. But if the coordinate value is a percentage, we still need to multiply this
// percentage with the viewport's bounding box, in a similar way as gradientUnits is set
// to "objectBoundingBox".
private final boolean mIsPercentage;
GradientCoordResult(double value, boolean isPercentage) {
mValue = value;
mIsPercentage = isPercentage;
}
double getValue() {
return mValue;
}
boolean isPercentage() {
return mIsPercentage;
}
}
protected enum GradientUsage {
FILL,
STROKE
}
// Maps the gradient vector's coordinate names to an int for easier array lookup.
private static final ImmutableMap<String, Integer> vectorCoordinateMap =
ImmutableMap.<String, Integer>builder()
.put("x1", 0)
.put("y1", 1)
.put("x2", 2)
.put("y2", 3)
.build();
SvgGradientNode(@NonNull SvgTree svgTree, @NonNull Element element, @Nullable String nodeName) {
super(svgTree, element, nodeName);
}
@Override
@NonNull
public SvgGradientNode deepCopy() {
SvgGradientNode newInstance = new SvgGradientNode(getTree(), mDocumentElement, getName());
newInstance.copyFrom(this);
return newInstance;
}
@Override
public boolean isGroupNode() {
return false;
}
/**
* We do not copy mSvgLeafNode, boundingBox, or mGradientUsage because they will be set after
* copying the SvgGradientNode. We always call deepCopy of SvgGradientNodes within a SvgLeafNode
* and then call setSvgLeafNode for that leaf. We calculate the boundingBox and determine the
* mGradientUsage based on the leaf node's attributes and reference to the gradient being
* copied.
*/
protected void copyFrom(@NonNull SvgGradientNode from) {
super.copyFrom(from);
for (GradientStop g : from.myGradientStops) {
addGradientStop(g.getColor(), g.getOffset(), g.getOpacity());
}
}
@Override
public void dumpNode(@NonNull String indent) {
// Print the current node.
logger.log(Level.FINE, indent + "current gradient is :" + getName());
}
@Override
public void transformIfNeeded(@NonNull AffineTransform rootTransform) {
AffineTransform finalTransform = new AffineTransform(rootTransform);
finalTransform.concatenate(mStackedTransform);
}
@Override
public void flatten(@NonNull AffineTransform transform) {
mStackedTransform.setTransform(transform);
mStackedTransform.concatenate(mLocalTransform);
}
/** Parses the gradient coordinate value given as a percentage or a length. Returns a double. */
private GradientCoordResult getGradientCoordinate(@NonNull String x, double defaultValue) {
if (!mVdAttributesMap.containsKey(x)) {
return new GradientCoordResult(defaultValue, false);
}
double val = defaultValue;
String vdValue = mVdAttributesMap.get(x).trim();
if (x.equals("r") && vdValue.startsWith("-")) {
return new GradientCoordResult(defaultValue, false);
}
boolean isPercentage = false;
try {
if (vdValue.endsWith("%")) {
val = Double.parseDouble(vdValue.substring(0, vdValue.length() - 1)) / 100;
isPercentage = true;
} else {
val = Double.parseDouble(vdValue);
}
} catch (NumberFormatException e) {
logError("Unsupported coordinate value");
}
return new GradientCoordResult(val, isPercentage);
}
@Override
public void writeXml(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
if (myGradientStops.isEmpty()) {
logError("Gradient has no stop info");
return;
}
// By default, the dimensions of the gradient is the bounding box of the path.
setBoundingBox();
double height = boundingBox.getHeight();
double width = boundingBox.getWidth();
double startX = boundingBox.getX();
double startY = boundingBox.getY();
String gradientUnit = mVdAttributesMap.get("gradientUnits");
boolean isUserSpaceOnUse = "userSpaceOnUse".equals(gradientUnit);
// If gradientUnits is specified to be "userSpaceOnUse", we use the image's dimensions.
if (isUserSpaceOnUse) {
startX = 0;
startY = 0;
height = getTree().getHeight();
width = getTree().getWidth();
}
if (width == 0 || height == 0) {
return; // The gradient is not visible because it doesn't occupy any area.
}
writer.write(indent);
if (mGradientUsage == GradientUsage.FILL) {
writer.write("<aapt:attr name=\"android:fillColor\">");
} else {
writer.write("<aapt:attr name=\"android:strokeColor\">");
}
writer.write(System.lineSeparator());
writer.write(indent);
writer.write(INDENT_UNIT);
writer.write("<gradient ");
// TODO: Fix matrix transformations that include skew element and SVGs that define scale before rotate.
// Additionally skew transformations have not been tested.
// If there is a gradientTransform, parse and store in mLocalTransform.
if (mVdAttributesMap.containsKey("gradientTransform")) {
String transformValue = mVdAttributesMap.get("gradientTransform");
parseLocalTransform(transformValue);
if (!isUserSpaceOnUse) {
AffineTransform tr = new AffineTransform(width, 0, 0, height, 0, 0);
mLocalTransform.preConcatenate(tr);
try {
tr.invert();
} catch (NoninvertibleTransformException e) {
throw new Error(e); // Not going to happen because width * height != 0;
}
mLocalTransform.concatenate(tr);
}
}
// According to the SVG spec, the gradient transformation (mLocalTransform) always needs
// to be applied to the gradient. However, the geometry transformation (mStackedTransform)
// will be affecting gradient only when it is using user space because we flatten
// everything.
// If we are not using user space, at this moment, the bounding box already contains
// the geometry transformation, when we apply percentage to the bounding box, we don't
// need to multiply the geometry transformation the second time.
if (isUserSpaceOnUse) {
mLocalTransform.preConcatenate(mSvgLeafNode.mStackedTransform);
}
// Source and target arrays to which we apply the local transform.
double[] gradientBounds;
double[] transformedBounds;
String gradientType = "linear";
if (mVdAttributesMap.containsKey("gradientType")) {
gradientType = mVdAttributesMap.get("gradientType");
}
if (gradientType.equals("linear")) {
gradientBounds = new double[4];
transformedBounds = new double[4];
// Retrieves x1, y1, x2, y2 and calculates their coordinate in the viewport.
// Stores the coordinates in the gradientBounds and transformedBounds arrays to apply
// the proper transformation.
for (Map.Entry<String, Integer> entry : vectorCoordinateMap.entrySet()) {
// Gets the index corresponding to x1, y1, x2 and y2.
// x1 and x2 are indexed as 0 and 2
// y1 and y2 are indexed as 1 and 3
String s = entry.getKey();
int index = entry.getValue();
// According to SVG spec, the default coordinate value for x1, and y1 and y2 is 0.
// The default for x2 is 1.
double defaultValue = 0;
if (index == 2) {
defaultValue = 1;
}
GradientCoordResult result = getGradientCoordinate(s, defaultValue);
double coordValue = result.getValue();
if (!isUserSpaceOnUse || result.isPercentage()) {
if (index % 2 == 0) {
coordValue = coordValue * width + startX;
} else {
coordValue = coordValue * height + startY;
}
}
// In case no transforms are applied, original coordinates are also stored in
// transformedBounds.
gradientBounds[index] = coordValue;
transformedBounds[index] = coordValue;
// We need mVdAttributesMap to contain all coordinates regardless if they are
// specified in the SVG in order to write the default value to the VD XML.
if (!mVdAttributesMap.containsKey(s)) {
mVdAttributesMap.put(s, "");
}
}
// transformedBounds will hold the new coordinates of the gradient.
// This applies it to the linearGradient
mLocalTransform.transform(gradientBounds, 0, transformedBounds, 0, 2);
} else {
gradientBounds = new double[2];
transformedBounds = new double[2];
GradientCoordResult cxResult = getGradientCoordinate("cx", .5);
double cx = cxResult.getValue();
if (!isUserSpaceOnUse || cxResult.isPercentage()) {
cx = width * cx + startX;
}
GradientCoordResult cyResult = getGradientCoordinate("cy", .5);
double cy = cyResult.getValue();
if (!isUserSpaceOnUse || cyResult.isPercentage()) {
cy = height * cy + startY;
}
GradientCoordResult rResult = getGradientCoordinate("r", .5);
double r = rResult.getValue();
if (!isUserSpaceOnUse || rResult.isPercentage()) {
r *= Math.max(height, width);
}
gradientBounds[0] = cx;
transformedBounds[0] = cx;
gradientBounds[1] = cy;
transformedBounds[1] = cy;
// Transform radius, center point here.
mLocalTransform.transform(gradientBounds, 0, transformedBounds, 0, 1);
Point2D radius = new Point2D.Double(r, 0);
Point2D transformedRadius = new Point2D.Double(r, 0);
mLocalTransform.deltaTransform(radius, transformedRadius);
mVdAttributesMap.put("cx", formatFloatValue(transformedBounds[0]));
mVdAttributesMap.put("cy", formatFloatValue(transformedBounds[1]));
mVdAttributesMap.put("r", formatFloatValue(transformedRadius.distance(0, 0)));
}
for (Map.Entry<String, String> entry : mVdAttributesMap.entrySet()) {
String key = entry.getKey();
String gradientAttr = Svg2Vector.gradientMap.get(key);
String svgValue = entry.getValue().trim();
String vdValue;
vdValue = colorSvg2Vd(svgValue, "#000000");
if (vdValue == null) {
if (vectorCoordinateMap.containsKey(key)) {
double x = transformedBounds[vectorCoordinateMap.get(key)];
vdValue = formatFloatValue(x);
} else if (key.equals("spreadMethod")) {
if (svgValue.equals("pad")) {
vdValue = "clamp";
} else if (svgValue.equals("reflect")) {
vdValue = "mirror";
} else if (svgValue.equals("repeat")) {
vdValue = "repeat";
} else {
logError("Unsupported spreadMethod " + svgValue);
vdValue = "clamp";
}
} else if (svgValue.endsWith("%")) {
vdValue = formatFloatValue(getGradientCoordinate(key, 0).getValue());
} else {
vdValue = svgValue;
}
}
if (!gradientAttr.isEmpty()) {
writer.write(System.lineSeparator());
writer.write(indent);
writer.write(INDENT_UNIT);
writer.write(CONTINUATION_INDENT);
writer.write(gradientAttr);
writer.write("=\"");
writer.write(vdValue);
writer.write("\"");
}
}
writer.write('>');
writer.write(System.lineSeparator());
writeGradientStops(writer, indent + INDENT_UNIT + INDENT_UNIT);
writer.write(indent);
writer.write(INDENT_UNIT);
writer.write("</gradient>");
writer.write(System.lineSeparator());
writer.write(indent);
writer.write("</aapt:attr>");
writer.write(System.lineSeparator());
}
private void writeGradientStops(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
for (GradientStop g : myGradientStops) {
String color = g.getColor();
float opacity;
try {
opacity = Float.parseFloat(g.getOpacity());
} catch (NumberFormatException e) {
logWarning("Unsupported opacity value");
opacity = 1;
}
int color1 = VdPath.applyAlpha(parseColorValue(color), opacity);
color = String.format("#%08X", color1);
writer.write(indent);
writer.write("<item android:offset=\"");
writer.write(trimInsignificantZeros(g.getOffset()));
writer.write("\"");
writer.write(" android:color=\"");
writer.write(color);
writer.write("\"/>");
writer.write(System.lineSeparator());
if (myGradientStops.size() == 1) {
logWarning("Gradient has only one color stop");
writer.write(indent);
writer.write("<item android:offset=\"1\"");
writer.write(" android:color=\"");
writer.write(color);
writer.write("\"/>");
writer.write(System.lineSeparator());
}
}
}
public void addGradientStop(
@NonNull String color, @NonNull String offset, @NonNull String opacity) {
GradientStop stop = new GradientStop(color, offset);
stop.setOpacity(opacity);
myGradientStops.add(stop);
}
public void setGradientUsage(@NonNull GradientUsage gradientUsage) {
mGradientUsage = gradientUsage;
}
public void setSvgLeafNode(@NonNull SvgLeafNode svgLeafNode) {
mSvgLeafNode = svgLeafNode;
}
private void setBoundingBox() {
Path2D svgPath = new Path2D.Double();
VdPath.Node[] nodes = PathParser.parsePath(mSvgLeafNode.getPathData(), ParseMode.SVG);
VdNodeRender.createPath(nodes, svgPath);
boundingBox = svgPath.getBounds2D();
}
}

View File

@ -0,0 +1,171 @@
/*
* 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.android.annotations.Nullable;
import java.awt.geom.AffineTransform;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.Element;
/**
* Represents an SVG file's group element.
*/
class SvgGroupNode extends SvgNode {
private static final Logger logger = Logger.getLogger(SvgGroupNode.class.getSimpleName());
protected final ArrayList<SvgNode> mChildren = new ArrayList<>();
SvgGroupNode(@NonNull SvgTree svgTree, @NonNull Element docNode, @Nullable String name) {
super(svgTree, docNode, name);
}
@Override
@NonNull
public SvgGroupNode deepCopy() {
SvgGroupNode newInstance = new SvgGroupNode(getTree(), mDocumentElement, getName());
newInstance.copyFrom(this);
return newInstance;
}
protected <T extends SvgGroupNode> void copyFrom(@NonNull T from) {
super.copyFrom(from);
for (SvgNode child : from.mChildren) {
addChild(child.deepCopy());
}
}
public void addChild(@NonNull SvgNode child) {
// Pass the presentation map down to the children, who can override the attributes.
mChildren.add(child);
// The child has its own attributes map. But the parents can still fill some attributes
// if they don't exist.
child.fillEmptyAttributes(mVdAttributesMap);
}
/**
* Replaces an existing child node with a new one.
*
* @param oldChild the child node to replace
* @param newChild the node to replace the existing child node with
*/
public void replaceChild(@NonNull SvgNode oldChild, @NonNull SvgNode newChild) {
int index = mChildren.indexOf(oldChild);
if (index < 0) {
throw new IllegalArgumentException(
"The child being replaced doesn't belong to this group");
}
mChildren.set(index, newChild);
}
@Override
public void dumpNode(@NonNull String indent) {
// Print the current group.
logger.log(Level.FINE, indent + "group: " + getName());
// Then print all the children.
for (SvgNode node : mChildren) {
node.dumpNode(indent + INDENT_UNIT);
}
}
/**
* Finds the parent node of the input node.
*
* @return the parent node, or null if node is not in the tree.
*/
@Nullable
public SvgGroupNode findParent(@NonNull SvgNode node) {
for (SvgNode n : mChildren) {
if (n == node) {
return this;
}
if (n.isGroupNode()) {
SvgGroupNode parent = ((SvgGroupNode) n).findParent(node);
if (parent != null) {
return parent;
}
}
}
return null;
}
@Override
public boolean isGroupNode() {
return true;
}
@Override
public void transformIfNeeded(@NonNull AffineTransform rootTransform) {
for (SvgNode p : mChildren) {
p.transformIfNeeded(rootTransform);
}
}
@Override
public void flatten(@NonNull AffineTransform transform) {
for (SvgNode node : mChildren) {
mStackedTransform.setTransform(transform);
mStackedTransform.concatenate(mLocalTransform);
node.flatten(mStackedTransform);
}
}
@Override
public void validate() {
for (SvgNode node : mChildren) {
node.validate();
}
}
@Override
public void writeXml(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
for (SvgNode node : mChildren) {
node.writeXml(writer, indent);
}
}
@Override
public VisitResult accept(@NonNull Visitor visitor) {
VisitResult result = visitor.visit(this);
if (result == VisitResult.CONTINUE) {
for (SvgNode node : mChildren) {
if (node.accept(visitor) == VisitResult.ABORT) {
return VisitResult.ABORT;
}
}
}
return result == VisitResult.SKIP_CHILDREN ? VisitResult.CONTINUE : result;
}
@Override
public void fillPresentationAttributes(@NonNull String name, @NonNull String value) {
super.fillPresentationAttributes(name, value);
for (SvgNode n : mChildren) {
// Group presentation attribute should not override child.
if (!n.mVdAttributesMap.containsKey(name)) {
n.fillPresentationAttributes(name, value);
}
}
}
}

View File

@ -0,0 +1,294 @@
/*
* 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 static com.android.ide.common.vectordrawable.Svg2Vector.SVG_FILL;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_FILL_OPACITY;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_OPACITY;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_STROKE;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_STROKE_OPACITY;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_STROKE_WIDTH;
import static com.android.ide.common.vectordrawable.Svg2Vector.presentationMap;
import static com.android.utils.XmlUtils.formatFloatValue;
import static java.lang.Math.abs;
import static java.lang.Math.sqrt;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.vectordrawable.PathParser.ParseMode;
import java.awt.geom.AffineTransform;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.Element;
/** Represents an SVG file's leaf element. */
class SvgLeafNode extends SvgNode {
private static final Logger logger = Logger.getLogger(SvgLeafNode.class.getSimpleName());
@Nullable private String mPathData;
@Nullable private SvgGradientNode mFillGradientNode;
@Nullable private SvgGradientNode mStrokeGradientNode;
SvgLeafNode(@NonNull SvgTree svgTree, @NonNull Element element, @Nullable String nodeName) {
super(svgTree, element, nodeName);
}
@Override
@NonNull
public SvgLeafNode deepCopy() {
SvgLeafNode newNode = new SvgLeafNode(getTree(), mDocumentElement, getName());
newNode.copyFrom(this);
return newNode;
}
protected void copyFrom(@NonNull SvgLeafNode from) {
super.copyFrom(from);
mPathData = from.mPathData;
}
/** Writes attributes of this node. */
private void writeAttributeValues(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
// There could be some redundant opacity information in the attributes' map,
// like opacity vs fill-opacity / stroke-opacity.
parsePathOpacity();
for (Map.Entry<String, String> entry : mVdAttributesMap.entrySet()) {
String name = entry.getKey();
String attribute = presentationMap.get(name);
if (attribute.isEmpty()) {
continue;
}
String svgValue = entry.getValue().trim();
String vdValue = colorSvg2Vd(svgValue, "#000000");
if (vdValue == null) {
if (svgValue.endsWith("px")) {
vdValue = svgValue.substring(0, svgValue.length() - 2).trim();
} else if (svgValue.startsWith("url(#") && svgValue.endsWith(")")) {
// Copies gradient from tree.
vdValue = svgValue.substring(5, svgValue.length() - 1);
if (name.equals(SVG_FILL)) {
SvgNode node = getTree().getSvgNodeFromId(vdValue);
if (node == null) {
continue;
}
mFillGradientNode = (SvgGradientNode)node.deepCopy();
mFillGradientNode.setSvgLeafNode(this);
mFillGradientNode.setGradientUsage(SvgGradientNode.GradientUsage.FILL);
} else if (name.equals(SVG_STROKE)) {
SvgNode node = getTree().getSvgNodeFromId(vdValue);
if (node == null) {
continue;
}
mStrokeGradientNode = (SvgGradientNode)node.deepCopy();
mStrokeGradientNode.setSvgLeafNode(this);
mStrokeGradientNode.setGradientUsage(SvgGradientNode.GradientUsage.STROKE);
}
continue;
} else {
vdValue = svgValue;
}
}
writer.write(System.lineSeparator());
writer.write(indent);
writer.write(CONTINUATION_INDENT);
writer.write(attribute);
writer.write("=\"");
writer.write(vdValue);
writer.write("\"");
}
}
/**
* Parses the SVG path's opacity attribute into fill and stroke.
*/
private void parsePathOpacity() {
double opacity = getOpacityValueFromMap(SVG_OPACITY);
double fillOpacity = getOpacityValueFromMap(SVG_FILL_OPACITY);
double strokeOpacity = getOpacityValueFromMap(SVG_STROKE_OPACITY);
putOpacityValueToMap(SVG_FILL_OPACITY, fillOpacity * opacity);
putOpacityValueToMap(SVG_STROKE_OPACITY, strokeOpacity * opacity);
mVdAttributesMap.remove(SVG_OPACITY);
}
/**
* A utility function to get the opacity value as a floating point number.
*
* @param attributeName the name of the opacity attribute
* @return the clamped opacity value, or 1 if not found
*/
private double getOpacityValueFromMap(@NonNull String attributeName) {
// Default opacity is 1.
double result = 1;
String opacity = mVdAttributesMap.get(attributeName);
if (opacity != null) {
try {
if (opacity.endsWith("%")) {
result = Double.parseDouble(opacity.substring(0, opacity.length() - 1)) / 100.;
} else {
result = Double.parseDouble(opacity);
}
} catch (NumberFormatException e) {
// Ignore here, an invalid value is replaced by the default value 1.
}
}
return Math.min(Math.max(result, 0), 1);
}
private void putOpacityValueToMap(@NonNull String attributeName, double opacity) {
String attributeValue = formatFloatValue(opacity);
if (attributeValue.equals("1")) {
mVdAttributesMap.remove(attributeName);
} else {
mVdAttributesMap.put(attributeName, attributeValue);
}
}
@Override
public void dumpNode(@NonNull String indent) {
logger.log(Level.FINE, indent + (mPathData != null ? mPathData : " null pathData ") +
(mName != null ? mName : " null name "));
}
public void setPathData(@NonNull String pathData) {
mPathData = pathData;
}
@Nullable
public String getPathData() {
return mPathData;
}
@Override
public boolean isGroupNode() {
return false;
}
public boolean hasGradient() {
return mFillGradientNode != null || mStrokeGradientNode != null;
}
@Override
public void transformIfNeeded(@NonNull AffineTransform rootTransform) {
if (mPathData == null || mPathData.isEmpty()) {
// Nothing to draw and transform, early return.
return;
}
VdPath.Node[] nodes = PathParser.parsePath(mPathData, ParseMode.SVG);
AffineTransform finalTransform = new AffineTransform(rootTransform);
finalTransform.concatenate(mStackedTransform);
boolean needsConvertRelativeMoveAfterClose = VdPath.Node.hasRelMoveAfterClose(nodes);
if (!finalTransform.isIdentity() || needsConvertRelativeMoveAfterClose) {
VdPath.Node.transform(finalTransform, nodes);
}
mPathData = VdPath.Node.nodeListToString(nodes, mSvgTree.getCoordinateFormat());
}
@Override
public void flatten(@NonNull AffineTransform transform) {
mStackedTransform.setTransform(transform);
mStackedTransform.concatenate(mLocalTransform);
if (!"non-scaling-stroke".equals(mVdAttributesMap.get("vector-effect"))
&& (mStackedTransform.getType() & AffineTransform.TYPE_MASK_SCALE) != 0) {
String strokeWidth = mVdAttributesMap.get(SVG_STROKE_WIDTH);
if (strokeWidth != null) {
try {
// Unlike SVG, vector drawable is not capable of applying transformations
// to stroke outline. To compensate for that we apply scaling transformation
// to the stroke width, which produces accurate results for uniform and
// approximate results for nonuniform scaling transformation.
double width = Double.parseDouble(strokeWidth);
double determinant = mStackedTransform.getDeterminant();
if (determinant != 0) {
width *= sqrt(abs(determinant));
mVdAttributesMap.put(SVG_STROKE_WIDTH, formatFloatValue(width));
}
if ((mStackedTransform.getType() & AffineTransform.TYPE_GENERAL_SCALE) != 0) {
logWarning("Scaling of the stroke width is approximate");
}
} catch (NumberFormatException ignore) {
}
}
}
}
@Override
public void writeXml(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException {
// First, decide whether or not we can skip this path, since it has no visible effect.
if (mPathData == null || mPathData.isEmpty()) {
return; // No path to draw.
}
String fillColor = mVdAttributesMap.get(SVG_FILL);
String strokeColor = mVdAttributesMap.get(SVG_STROKE);
logger.log(Level.FINE, "fill color " + fillColor);
boolean emptyFill = "none".equals(fillColor) || "#00000000".equals(fillColor);
boolean emptyStroke = strokeColor == null || "none".equals(strokeColor);
if (emptyFill && emptyStroke) {
return; // Nothing to draw.
}
// Second, write the color info handling the default values.
writer.write(indent);
writer.write("<path");
writer.write(System.lineSeparator());
if (fillColor == null && mFillGradientNode == null) {
logger.log(Level.FINE, "Adding default fill color");
writer.write(indent);
writer.write(CONTINUATION_INDENT);
writer.write("android:fillColor=\"#FF000000\"");
writer.write(System.lineSeparator());
}
if (!emptyStroke
&& !mVdAttributesMap.containsKey(SVG_STROKE_WIDTH)
&& mStrokeGradientNode == null) {
logger.log(Level.FINE, "Adding default stroke width");
writer.write(indent);
writer.write(CONTINUATION_INDENT);
writer.write("android:strokeWidth=\"1\"");
writer.write(System.lineSeparator());
}
// Last, write the path data and all associated attributes.
writer.write(indent);
writer.write(CONTINUATION_INDENT);
writer.write("android:pathData=\"" + mPathData + "\"");
writeAttributeValues(writer, indent);
if (!hasGradient()) {
writer.write('/');
}
writer.write('>');
writer.write(System.lineSeparator());
if (mFillGradientNode != null) {
mFillGradientNode.writeXml(writer, indent + INDENT_UNIT);
}
if (mStrokeGradientNode != null) {
mStrokeGradientNode.writeXml(writer, indent + INDENT_UNIT);
}
if (hasGradient()) {
writer.write(indent);
writer.write("</path>");
writer.write(System.lineSeparator());
}
}
}

View File

@ -0,0 +1,322 @@
/*
* 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 static com.android.ide.common.vectordrawable.Svg2Vector.SVG_CLIP_RULE;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_FILL;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_FILL_RULE;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_STROKE;
import static com.android.ide.common.vectordrawable.Svg2Vector.SVG_STROKE_WIDTH;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import java.awt.geom.AffineTransform;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
/** Parent class for a SVG file's node, can be either group or leaf element. */
abstract class SvgNode {
private static final Logger logger = Logger.getLogger(SvgNode.class.getSimpleName());
protected static final String INDENT_UNIT = " ";
protected static final String CONTINUATION_INDENT = INDENT_UNIT + INDENT_UNIT;
private static final String TRANSFORM_TAG = "transform";
private static final String MATRIX_ATTRIBUTE = "matrix";
private static final String TRANSLATE_ATTRIBUTE = "translate";
private static final String ROTATE_ATTRIBUTE = "rotate";
private static final String SCALE_ATTRIBUTE = "scale";
private static final String SKEWX_ATTRIBUTE = "skewX";
private static final String SKEWY_ATTRIBUTE = "skewY";
protected final String mName;
// Keep a reference to the tree in order to dump the error log.
protected final SvgTree mSvgTree;
// Use document element to get the line number for error reporting.
protected final Element mDocumentElement;
// Key is the attributes for vector drawable, and the value is the converted from SVG.
protected final Map<String, String> mVdAttributesMap = new HashMap<>();
// If mLocalTransform is identity, it is the same as not having any transformation.
protected AffineTransform mLocalTransform = new AffineTransform();
// During the flatten() operation, we need to merge the transformation from top down.
// This is the stacked transformation. And this will be used for the path data transform().
protected AffineTransform mStackedTransform = new AffineTransform();
/** While parsing the translate() rotate() ..., update the {@code mLocalTransform}. */
SvgNode(@NonNull SvgTree svgTree, @NonNull Element element, @Nullable String name) {
mName = name;
mSvgTree = svgTree;
mDocumentElement = element;
// Parse and generate a presentation map.
NamedNodeMap a = element.getAttributes();
int len = a.getLength();
for (int itemIndex = 0; itemIndex < len; itemIndex++) {
Node n = a.item(itemIndex);
String nodeName = n.getNodeName();
String nodeValue = n.getNodeValue();
// TODO: Handle style here. Refer to Svg2Vector::addStyleToPath().
if (Svg2Vector.presentationMap.containsKey(nodeName)) {
fillPresentationAttributesInternal(nodeName, nodeValue);
}
if (TRANSFORM_TAG.equals(nodeName)) {
logger.log(Level.FINE, nodeName + " " + nodeValue);
parseLocalTransform(nodeValue);
}
}
}
protected void parseLocalTransform(@NonNull String nodeValue) {
// We separate the string into multiple parts and look like this:
// "translate" "30" "rotate" "4.5e1 5e1 50"
nodeValue = nodeValue.replaceAll(",", " ");
String[] matrices = nodeValue.split("[()]");
AffineTransform parsedTransform;
for (int i = 0; i < matrices.length - 1; i += 2) {
parsedTransform = parseOneTransform(matrices[i].trim(), matrices[i + 1].trim());
if (parsedTransform != null) {
mLocalTransform.concatenate(parsedTransform);
}
}
}
@Nullable
private static AffineTransform parseOneTransform(String type, String data) {
float[] numbers = getNumbers(data);
if (numbers == null) {
return null;
}
int numLength = numbers.length;
AffineTransform parsedTransform = new AffineTransform();
if (MATRIX_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 6) {
return null;
}
parsedTransform.setTransform(
numbers[0], numbers[1], numbers[2], numbers[3], numbers[4], numbers[5]);
} else if (TRANSLATE_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 1 && numLength != 2) {
return null;
}
// Default translateY is 0
parsedTransform.translate(numbers[0], numLength == 2 ? numbers[1] : 0);
} else if (SCALE_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 1 && numLength != 2) {
return null;
}
// Default scaleY == scaleX
parsedTransform.scale(numbers[0], numbers[numLength == 2 ? 1 : 0]);
} else if (ROTATE_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 1 && numLength != 3) {
return null;
}
parsedTransform.rotate(
Math.toRadians(numbers[0]),
numLength == 3 ? numbers[1] : 0,
numLength == 3 ? numbers[2] : 0);
} else if (SKEWX_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 1) {
return null;
}
// Note that Swing is pass the shear value directly to the matrix as m01 or m10,
// while SVG is using tan(a) in the matrix and a is in radians.
parsedTransform.shear(Math.tan(Math.toRadians(numbers[0])), 0);
} else if (SKEWY_ATTRIBUTE.equalsIgnoreCase(type)) {
if (numLength != 1) {
return null;
}
parsedTransform.shear(0, Math.tan(Math.toRadians(numbers[0])));
}
return parsedTransform;
}
@Nullable
private static float[] getNumbers(String data) {
String[] numbers = data.split("\\s+");
int len = numbers.length;
if (len == 0) {
return null;
}
float[] results = new float[len];
for (int i = 0; i < len; i++) {
results[i] = Float.parseFloat(numbers[i]);
}
return results;
}
@NonNull
protected SvgTree getTree() {
return mSvgTree;
}
@Nullable
public String getName() {
return mName;
}
@NonNull
public Element getDocumentElement() {
return mDocumentElement;
}
/** Dumps the current node's debug info. */
public abstract void dumpNode(@NonNull String indent);
/**
* Writes content of the node into the VectorDrawable's XML file.
*
* @param writer the writer to write the group XML element to
* @param indent whitespace used for indenting output XML
*/
public abstract void writeXml(@NonNull OutputStreamWriter writer, @NonNull String indent)
throws IOException;
/**
* Calls the {@linkplain Visitor#visit(SvgNode)} method for this node and its descendants.
*
* @param visitor the visitor to accept
*/
public VisitResult accept(@NonNull Visitor visitor) {
return visitor.visit(this);
}
/** Returns true the node is a group node. */
public abstract boolean isGroupNode();
/** Transforms the current Node with the transformation matrix. */
public abstract void transformIfNeeded(@NonNull AffineTransform finalTransform);
private void fillPresentationAttributesInternal(@NonNull String name, @NonNull String value) {
if (name.equals(SVG_FILL_RULE) || name.equals(SVG_CLIP_RULE)) {
if (value.equals("nonzero")) {
value = "nonZero";
} else if (value.equals("evenodd")) {
value = "evenOdd";
}
}
logger.log(Level.FINE, ">>>> PROP " + name + " = " + value);
if (value.startsWith("url(")) {
if (!name.equals(SVG_FILL) && !name.equals(SVG_STROKE)) {
logError("Unsupported URL value: " + value);
return;
}
}
if (name.equals(SVG_STROKE_WIDTH) && value.equals("0")) {
mVdAttributesMap.remove(SVG_STROKE);
}
mVdAttributesMap.put(name, value);
}
protected void fillPresentationAttributes(@NonNull String name, @NonNull String value) {
fillPresentationAttributesInternal(name, value);
}
public void fillEmptyAttributes(@NonNull Map<String, String> parentAttributesMap) {
// Go through the parents' attributes, if the child misses any, then fill it.
for (Map.Entry<String, String> entry : parentAttributesMap.entrySet()) {
String name = entry.getKey();
if (!mVdAttributesMap.containsKey(name)) {
mVdAttributesMap.put(name, entry.getValue());
}
}
}
public abstract void flatten(@NonNull AffineTransform transform);
/**
* Checks validity of the node and logs any issues associated with it. Subclasses may override.
*/
public void validate() {}
/**
* Returns a string containing the value of the given attribute. Returns an empty string if
* the attribute does not exist.
*/
public String getAttributeValue(@NonNull String attribute) {
return mDocumentElement.getAttribute(attribute);
}
@NonNull
public abstract SvgNode deepCopy();
protected <T extends SvgNode> void copyFrom(@NonNull T from) {
fillEmptyAttributes(from.mVdAttributesMap);
mLocalTransform = (AffineTransform) from.mLocalTransform.clone();
}
/**
* Converts an SVG color value to "#RRGGBB" or "#RGB" format used by vector drawables. The input
* color value can be "none" and RGB value, e.g. "rgb(255, 0, 0)", or a color name defined in
* https://www.w3.org/TR/SVG11/types.html#ColorKeywords.
*
* @param svgColor the SVG color value to convert
* @param errorFallbackColor the value returned if the supplied SVG color value has invalid or
* unsupported format
* @return the converted value, or null if the given value cannot be interpreted as color
*/
@Nullable
protected String colorSvg2Vd(@NonNull String svgColor, @NonNull String errorFallbackColor) {
try {
return SvgColor.colorSvg2Vd(svgColor);
} catch (IllegalArgumentException e) {
logError("Unsupported color format \"" + svgColor + "\"");
return errorFallbackColor;
}
}
protected void logError(@NonNull String s) {
mSvgTree.logError(s, mDocumentElement);
}
protected void logWarning(@NonNull String s) {
mSvgTree.logWarning(s, mDocumentElement);
}
protected interface Visitor {
/**
* Called by the {@link SvgNode#accept(Visitor)} method for every visited node.
*
* @param node the node being visited
* @return {@link VisitResult#CONTINUE} to continue visiting children,
* {@link VisitResult#SKIP_CHILDREN} to skip children and continue visit with
* the next sibling, {@link VisitResult#ABORT} to skip all remaining nodes
*/
VisitResult visit(@NonNull SvgNode node);
}
protected enum VisitResult {
CONTINUE,
SKIP_CHILDREN,
ABORT
}
protected enum ClipRule {
NON_ZERO,
EVEN_ODD
}
}

View File

@ -0,0 +1,501 @@
/*
* 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 static com.android.ide.common.vectordrawable.SvgNode.CONTINUATION_INDENT;
import static com.android.ide.common.vectordrawable.SvgNode.INDENT_UNIT;
import static com.android.utils.PositionXmlParser.getPosition;
import static com.android.utils.XmlUtils.formatFloatValue;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.utils.Pair;
import com.android.utils.PositionXmlParser;
import com.google.common.base.Preconditions;
import java.awt.geom.AffineTransform;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
/**
* Represents the SVG file in an internal data structure as a tree.
*/
class SvgTree {
private static final Logger logger = Logger.getLogger(SvgTree.class.getSimpleName());
private static final String HEAD =
"<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"";
private static final String AAPT_BOUND = "xmlns:aapt=\"http://schemas.android.com/aapt\"";
public static final String SVG_WIDTH = "width";
public static final String SVG_HEIGHT = "height";
public static final String SVG_VIEW_BOX = "viewBox";
private float w = -1;
private float h = -1;
private final AffineTransform mRootTransform = new AffineTransform();
private float[] viewBox;
private float mScaleFactor = 1;
private SvgGroupNode mRoot;
private String mFileName;
private final List<LogMessage> mLogMessages = new ArrayList<>();
private boolean mHasLeafNode;
private boolean mHasGradient;
/** Map of SvgNode's id to the SvgNode. */
private final Map<String, SvgNode> mIdMap = new HashMap<>();
/** IDs of ignored SVG nodes. */
private final Set<String> mIgnoredIds = new HashSet<>();
/** Set of SvgGroupNodes that contain use elements. */
private final Set<SvgGroupNode> mPendingUseGroupSet = new HashSet<>();
/**
* Key is SvgNode that references a clipPath. Value is SvgGroupNode that is the parent of that
* SvgNode.
*/
private final Map<SvgNode, Pair<SvgGroupNode, String>> mClipPathAffectedNodes =
new LinkedHashMap<>();
/**
* Key is String that is the id of a style class. Value is set of SvgNodes referencing that
* class.
*/
private final Map<String, Set<SvgNode>> mStyleAffectedNodes = new HashMap<>();
/**
* Key is String that is the id of a style class. Value is a String that contains attribute
* information of that style class.
*/
private final Map<String, String> mStyleClassAttributeMap = new HashMap<>();
enum SvgLogLevel {
ERROR,
WARNING
}
private static class LogMessage implements Comparable<LogMessage> {
final SvgLogLevel level;
final int line;
final String message;
/**
* Initializes a log message.
*
* @param level the severity level
* @param line the line number of the SVG file the message applies to,
* or zero if the message applies to the whole file
* @param message the text of the message
*/
LogMessage(@NonNull SvgLogLevel level, int line, @NonNull String message) {
this.level = level;
this.line = line;
this.message = message;
}
@NonNull
String getFormattedMessage() {
return level.name() + (line == 0 ? "" : " @ line " + line) + ": " + message;
}
@Override
public int compareTo(@NonNull LogMessage other) {
int cmp = level.compareTo(other.level);
if (cmp != 0) {
return cmp;
}
cmp = Integer.compare(line, other.line);
if (cmp != 0) {
return cmp;
}
return message.compareTo(other.message);
}
}
public float getWidth() {
return w;
}
public float getHeight() {
return h;
}
public float getScaleFactor() {
return mScaleFactor;
}
public void setHasLeafNode(boolean hasLeafNode) {
mHasLeafNode = hasLeafNode;
}
public void setHasGradient(boolean hasGradient) {
mHasGradient = hasGradient;
}
public float[] getViewBox() {
return viewBox;
}
/** From the root, top down, pass the transformation (TODO: attributes) down the children. */
public void flatten() {
mRoot.flatten(new AffineTransform());
}
/** Validates all nodes and logs any encountered issues. */
public void validate() {
mRoot.validate();
if (mLogMessages.isEmpty() && !getHasLeafNode()) {
logError("No vector content found", null);
}
}
public Document parse(@NonNull File f, @NonNull List<String> parseErrors) throws IOException {
mFileName = f.getName();
try {
BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(f));
return PositionXmlParser.parse(inputStream, false, parseErrors);
} catch (ParserConfigurationException e) {
throw new Error("Internal error", e); // Should not happen unless there is a bug.
}
}
public void normalize() {
// mRootTransform is always setup, now just need to apply the viewbox info into.
mRootTransform.preConcatenate(new AffineTransform(1, 0, 0, 1, -viewBox[0], -viewBox[1]));
transform(mRootTransform);
logger.log(Level.FINE, "matrix=" + mRootTransform);
}
private void transform(@NonNull AffineTransform rootTransform) {
mRoot.transformIfNeeded(rootTransform);
}
public void dump() {
logger.log(Level.FINE, "file: " + mFileName);
mRoot.dumpNode("");
}
public void setRoot(@NonNull SvgGroupNode root) {
mRoot = root;
}
@Nullable
public SvgGroupNode getRoot() {
return mRoot;
}
public void logError(@NonNull String s, @Nullable Node node) {
logErrorLine(s, node, SvgLogLevel.ERROR);
}
public void logWarning(@NonNull String s, @Nullable Node node) {
logErrorLine(s, node, SvgLogLevel.WARNING);
}
void logErrorLine(@NonNull String s, @Nullable Node node, @NonNull SvgLogLevel level) {
Preconditions.checkArgument(!s.isEmpty());
int line = node == null ? 0 : getStartLine(node);
mLogMessages.add(new LogMessage(level, line, s));
}
/**
* Returns the error message that combines all logged errors and warnings. If there were no
* errors, returns an empty string.
*/
@NonNull
public String getErrorMessage() {
if (mLogMessages.isEmpty()) {
return "";
}
Collections.sort(mLogMessages); // Sort by severity and line number.
StringBuilder result = new StringBuilder();
for (LogMessage message : mLogMessages) {
if (result.length() != 0) {
result.append('\n');
}
result.append(message.getFormattedMessage());
}
return result.toString();
}
/** Returns true when there is at least one valid child. */
public boolean getHasLeafNode() {
return mHasLeafNode;
}
public boolean getHasGradient() {
return mHasGradient;
}
/** Returns the 1-based start line number of the given node. */
public static int getStartLine(@NonNull Node node) {
return getPosition(node).getStartLine() + 1;
}
public float getViewportWidth() {
return (viewBox == null) ? -1 : viewBox[2];
}
public float getViewportHeight() { return (viewBox == null) ? -1 : viewBox[3]; }
private enum SizeType {
PIXEL,
PERCENTAGE
}
public void parseDimension(@NonNull Node nNode) {
NamedNodeMap a = nNode.getAttributes();
int len = a.getLength();
SizeType widthType = SizeType.PIXEL;
SizeType heightType = SizeType.PIXEL;
for (int i = 0; i < len; i++) {
Node n = a.item(i);
String name = n.getNodeName().trim();
String value = n.getNodeValue().trim();
int subStringSize = value.length();
SizeType currentType = SizeType.PIXEL;
String unit = value.substring(Math.max(value.length() - 2, 0));
if (unit.matches("em|ex|px|in|cm|mm|pt|pc")) {
subStringSize -= 2;
} else if (value.endsWith("%")) {
subStringSize -= 1;
currentType = SizeType.PERCENTAGE;
}
if (SVG_WIDTH.equals(name)) {
w = Float.parseFloat(value.substring(0, subStringSize));
widthType = currentType;
} else if (SVG_HEIGHT.equals(name)) {
h = Float.parseFloat(value.substring(0, subStringSize));
heightType = currentType;
} else if (SVG_VIEW_BOX.equals(name)) {
viewBox = new float[4];
String[] strbox = value.split(" ");
for (int j = 0; j < viewBox.length; j++) {
viewBox[j] = Float.parseFloat(strbox[j]);
}
}
}
// If there is no viewbox, then set it up according to w, h.
// From now on, viewport should be read from viewBox, and size should be from w and h.
// w and h can be set to percentage too, in this case, set it to the viewbox size.
if (viewBox == null && w > 0 && h > 0) {
viewBox = new float[4];
viewBox[2] = w;
viewBox[3] = h;
} else if ((w < 0 || h < 0) && viewBox != null) {
w = viewBox[2];
h = viewBox[3];
}
if (widthType == SizeType.PERCENTAGE && w > 0) {
w = viewBox[2] * w / 100;
}
if (heightType == SizeType.PERCENTAGE && h > 0) {
h = viewBox[3] * h / 100;
}
}
/**
* Parses an X coordinate of a width value that can be an absolute number or percentage of
* the viewport size.
*
* @param value the value to parse
* @return the parsed value
* @throws IllegalArgumentException if the value is not a valid floating point number or
* percentage
*/
public double parseXValue(@NonNull String value) {
return parseCoordinateOrLength(value, getViewportWidth());
}
/**
* Parses an Y coordinate of a height value that can be an absolute number or percentage of
* the viewport size.
*
* @param value the value to parse
* @return the parsed value
* @throws IllegalArgumentException if the value is not a valid floating point number or
* percentage
*/
public double parseYValue(@NonNull String value) {
return parseCoordinateOrLength(value, getViewportHeight());
}
private static double parseCoordinateOrLength(@NonNull String value, double percentageBase) {
if (value.endsWith("%")) {
return Double.parseDouble(value.substring(0, value.length() - 1)) / 100
* percentageBase;
} else {
return Double.parseDouble(value);
}
}
public void addIdToMap(@NonNull String id, @NonNull SvgNode svgNode) {
mIdMap.put(id, svgNode);
}
@Nullable
public SvgNode getSvgNodeFromId(@NonNull String id) {
return mIdMap.get(id);
}
public void addToPendingUseSet(@NonNull SvgGroupNode useGroup) {
mPendingUseGroupSet.add(useGroup);
}
@NonNull
public Set<SvgGroupNode> getPendingUseSet() {
return mPendingUseGroupSet;
}
public void addIgnoredId(@NonNull String id) {
mIgnoredIds.add(id);
}
public boolean isIdIgnored(@NonNull String id) {
return mIgnoredIds.contains(id);
}
public void addClipPathAffectedNode(
@NonNull SvgNode child,
@NonNull SvgGroupNode currentGroup,
@NonNull String clipPathName) {
mClipPathAffectedNodes.put(child, Pair.of(currentGroup, clipPathName));
}
@NonNull
public Set<Map.Entry<SvgNode, Pair<SvgGroupNode, String>>> getClipPathAffectedNodesSet() {
return mClipPathAffectedNodes.entrySet();
}
/** Adds child to set of SvgNodes that reference the style class with id className. */
public void addAffectedNodeToStyleClass(@NonNull String className, @NonNull SvgNode child) {
if (mStyleAffectedNodes.containsKey(className)) {
mStyleAffectedNodes.get(className).add(child);
} else {
Set<SvgNode> styleNodesSet = new HashSet<>();
styleNodesSet.add(child);
mStyleAffectedNodes.put(className, styleNodesSet);
}
}
public void addStyleClassToTree(@NonNull String className, @NonNull String attributes) {
mStyleClassAttributeMap.put(className, attributes);
}
@Nullable
public String getStyleClassAttr(@NonNull String classname) {
return mStyleClassAttributeMap.get(classname);
}
@NonNull
public Set<Map.Entry<String, Set<SvgNode>>> getStyleAffectedNodes() {
return mStyleAffectedNodes.entrySet();
}
/**
* Finds the parent node of the input node.
*
* @return the parent node, or null if node is not in the tree.
*/
@Nullable
public SvgGroupNode findParent(@NonNull SvgNode node) {
return mRoot.findParent(node);
}
/**
* Returns a {@link NumberFormat] of sufficient precision to use for formatting coordinate
* values within the viewport.
*/
@NonNull
public NumberFormat getCoordinateFormat() {
float viewportWidth = getViewportWidth();
float viewportHeight = getViewportHeight();
return VdUtil.getCoordinateFormat(Math.max(viewportHeight, viewportWidth));
}
public void writeXml(@NonNull OutputStream stream) throws IOException {
if (mRoot == null) {
throw new IllegalStateException("SvgTree is not fully initialized");
}
OutputStreamWriter writer = new OutputStreamWriter(stream, UTF_8);
writer.write(HEAD);
writer.write(System.lineSeparator());
if (getHasGradient()) {
writer.write(CONTINUATION_INDENT);
writer.write(AAPT_BOUND);
writer.write(System.lineSeparator());
}
float viewportWidth = getViewportWidth();
float viewportHeight = getViewportHeight();
writer.write(CONTINUATION_INDENT);
writer.write("android:width=\"");
writer.write(formatFloatValue(getWidth() * getScaleFactor()));
writer.write("dp\"");
writer.write(System.lineSeparator());
writer.write(CONTINUATION_INDENT);
writer.write("android:height=\"");
writer.write(formatFloatValue(getHeight() * getScaleFactor()));
writer.write("dp\"");
writer.write(System.lineSeparator());
writer.write(CONTINUATION_INDENT);
writer.write("android:viewportWidth=\"");
writer.write(formatFloatValue(viewportWidth));
writer.write("\"");
writer.write(System.lineSeparator());
writer.write(CONTINUATION_INDENT);
writer.write("android:viewportHeight=\"");
writer.write(formatFloatValue(viewportHeight));
writer.write("\">");
writer.write(System.lineSeparator());
normalize();
mRoot.writeXml(writer, INDENT_UNIT);
writer.write("</vector>");
writer.write(System.lineSeparator());
writer.close();
}
}

View File

@ -0,0 +1,41 @@
/*
* 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 java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import org.w3c.dom.NamedNodeMap;
/** Used to represent one VectorDrawable's element, can be a group or path. */
abstract class VdElement {
String mName;
boolean isClipPath;
public String getName() {
return mName;
}
public abstract void draw(Graphics2D g, AffineTransform currentMatrix, float scaleX, float scaleY);
public abstract void parseAttributes(NamedNodeMap attributes);
public abstract boolean isGroup();
public void setClipPath(boolean isClip) {
isClipPath = isClip;
}
}

View File

@ -0,0 +1,173 @@
/*
* 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 java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.NamedNodeMap;
/**
* Used to represent one VectorDrawble's group element.
*/
class VdGroup extends VdElement{
private static final Logger logger = Logger.getLogger(VdGroup.class.getSimpleName());
private static final String GROUP_ROTATION = "android:rotation";
private static final String GROUP_PIVOTX = "android:pivotX";
private static final String GROUP_PIVOTY = "android:pivotY";
private static final String GROUP_TRANSLATEX = "android:translateX";
private static final String GROUP_TRANSLATEY = "android:translateY";
private static final String GROUP_SCALEX = "android:scaleX";
private static final String GROUP_SCALEY = "android:scaleY";
private static final String GROUP_NAME = "android:name";
private float mRotate = 0;
private float mPivotX = 0;
private float mPivotY = 0;
private float mScaleX = 1;
private float mScaleY = 1;
private float mTranslateX = 0;
private float mTranslateY = 0;
// Used at draw time, basically accumulative matrix from root to current group.
private final AffineTransform mTempStackedMatrix = new AffineTransform();
// The current group's transformation.
private final AffineTransform mLocalMatrix = new AffineTransform();
// Children can be either a {@link VdPath} or {@link VdGroup}
private final ArrayList<VdElement> mChildren = new ArrayList<>();
public void add(VdElement pathOrGroup) {
mChildren.add(pathOrGroup);
}
public ArrayList<VdElement> getChildren() {
return mChildren;
}
public int size() {
return mChildren.size();
}
// Src = trans * src, this is called preConcatenate() in Swing, but postConcatenate() in Android
private static void androidPostTransform(AffineTransform src, AffineTransform trans) {
src.preConcatenate(trans);
}
private void updateLocalMatrix() {
// The order we apply is the same as the
// RenderNode.cpp::applyViewPropertyTransforms().
mLocalMatrix.setToIdentity();
// In Android framework, the transformation is applied in
// VectorDrawable.java VGroup::updateLocalMatrix()
AffineTransform tempTrans = new AffineTransform();
tempTrans.setToIdentity();
tempTrans.translate(-mPivotX, -mPivotY);
androidPostTransform(mLocalMatrix, tempTrans);
tempTrans.setToIdentity();
tempTrans.scale(mScaleX, mScaleY);
androidPostTransform(mLocalMatrix, tempTrans);
tempTrans.setToIdentity();
tempTrans.rotate(mRotate * 3.1415926 / 180, 0, 0);
androidPostTransform(mLocalMatrix, tempTrans);
tempTrans.setToIdentity();
tempTrans.translate(mTranslateX + mPivotX, mTranslateY + mPivotY);
androidPostTransform(mLocalMatrix, tempTrans);
}
@Override
public void draw(Graphics2D g, AffineTransform currentMatrix, float scaleX, float scaleY) {
// SWING default is pre-concatenate
mTempStackedMatrix.setTransform(currentMatrix);
mTempStackedMatrix.concatenate(mLocalMatrix);
for (VdElement m : mChildren) {
m.draw(g, mTempStackedMatrix, scaleX, scaleY);
}
// This only applies to the flattened SVG tree structure.
// One clip-path applies to a single group.
g.setClip(null);
}
private void setNameValue(String name, String value) {
if (GROUP_ROTATION.equals(name)) {
mRotate = Float.parseFloat(value);
} else if (GROUP_PIVOTX.equals(name)) {
mPivotX = Float.parseFloat(value);
} else if (GROUP_PIVOTY.equals(name)) {
mPivotY = Float.parseFloat(value);
} else if (GROUP_TRANSLATEX.equals(name)) {
mTranslateX = Float.parseFloat(value);
} else if (GROUP_TRANSLATEY.equals(name)) {
mTranslateY = Float.parseFloat(value);
} else if (GROUP_SCALEX.equals(name)) {
mScaleX = Float.parseFloat(value);
} else if (GROUP_SCALEY.equals(name)) {
mScaleY = Float.parseFloat(value);
} else if (GROUP_NAME.equals(name)) {
mName = value;
} else {
logger.log(Level.WARNING, ">>>>>> DID NOT UNDERSTAND ! \"" + name + "\" <<<<");
}
}
@Override
public void parseAttributes(NamedNodeMap attributes) {
int len = attributes.getLength();
for (int i = 0; i < len; i++) {
String name = attributes.item(i).getNodeName();
String value = attributes.item(i).getNodeValue();
setNameValue(name, value);
}
updateLocalMatrix();
}
@Override
public boolean isGroup() {
return true;
}
@Override
public String toString() {
return "Group:" +
" Name: " +
mName +
" mTranslateX: " +
mTranslateX +
" mTranslateY:" +
mTranslateY +
" mScaleX:" +
mScaleX +
" mScaleY:" +
mScaleY +
" mPivotX:" +
mPivotX +
" mPivotY:" +
mPivotY +
" mRotate:" +
mRotate;
}
}

View File

@ -0,0 +1,330 @@
/*
* 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 static com.android.SdkConstants.DOT_XML;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.KEY_TEXT_ANTIALIASING;
import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON;
import static java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.util.AssetUtil;
import java.awt.Color;
import java.awt.Component;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.Toolkit;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.BufferedImageOp;
import java.awt.image.ByteLookupTable;
import java.awt.image.LookupOp;
import java.io.IOException;
import java.net.URL;
import java.util.Map;
import javax.swing.Icon;
/**
* VdIcon wraps every vector drawable from Material Library into an icon. All of them are shown in
* a table for developer to pick.
*/
public class VdIcon implements Icon, Comparable<VdIcon> {
/** Common prefix for most of the vector icons */
private static final String ICON_PREFIX = "ic_";
/** Common prefix for material icons */
private static final String FILLED_PREFIX = "baseline_";
/** Common prefix for material icons */
private static final String OUTLINE_PREFIX = "outline_";
/** Common prefix for material icons */
private static final String ROUND_PREFIX = "round_";
/** Common prefix for material icons */
private static final String SHARP_PREFIX = "sharp_";
/** Common prefix for material icons */
private static final String TWO_TONE_PREFIX = "twotone_";
/** Common suffix for most of the vector icons */
private static final String ICON_SUFFIX = "_24.xml";
/** Distance between the icon and the label */
public static final int LABEL_GAP = 10;
private final VdTree mVdTree;
private final String mName;
private final URL mUrl;
private boolean mDrawCheckerBoardBackground;
private String mDisplayName;
private boolean mShowName;
private final Rectangle myRectangle = new Rectangle();
private final int mWidth;
private final int mHeight;
private final Color mBackground;
@SuppressWarnings({"InspectionUsingGrayColors", "UseJBColor"})
private static final Color CHECKER_COLOR = new Color(238, 238, 238);
private static final byte[] COLOR_INVERSION_TABLE = new byte[256];
static {
for (int counter = 0; counter < 256; counter++) {
COLOR_INVERSION_TABLE[counter] = (byte) (3 * (255 - counter) / 4);
}
}
public VdIcon(@NonNull URL url) throws IOException {
this(url, 0, 0);
}
public VdIcon(@NonNull URL url, int width, int height) throws IOException {
mVdTree = parseVdTree(url);
mUrl = url;
String fileName = url.getFile();
mName = fileName.substring(fileName.lastIndexOf('/') + 1);
if (width != 0 && height != 0) {
mWidth = width;
mHeight = height;
}
else {
mWidth = (int)mVdTree.getPortWidth();
mHeight = (int)mVdTree.getPortHeight();
}
mBackground = null;
}
public VdIcon(VdIcon icon, Color background) {
mVdTree = icon.mVdTree;
mUrl = icon.mUrl;
mName = icon.mName;
mWidth = icon.mWidth;
mHeight = icon.mHeight;
mBackground = background;
}
@NonNull
public String getName() {
return mName;
}
@NonNull
public String getDisplayName() {
if (mDisplayName == null) {
// Turn a filename into a reasonable display name, similar to what
// is shown on https://design.google.com/icons/ . For example, change
// "ic_supervisor_account_black_24dp.xml" into "supervisor account"
int begin = 0;
if (mName.startsWith(ICON_PREFIX)) {
begin = ICON_PREFIX.length();
} else if (mName.startsWith(FILLED_PREFIX)) {
begin = FILLED_PREFIX.length();
} else if (mName.startsWith(OUTLINE_PREFIX)) {
begin = OUTLINE_PREFIX.length();
} else if (mName.startsWith(ROUND_PREFIX)) {
begin = ROUND_PREFIX.length();
} else if (mName.startsWith(SHARP_PREFIX)) {
begin = SHARP_PREFIX.length();
} else if (mName.startsWith(TWO_TONE_PREFIX)) {
begin = TWO_TONE_PREFIX.length();
}
int end = mName.length();
if (mName.endsWith(ICON_SUFFIX)) {
end -= ICON_SUFFIX.length();
} else if (mName.endsWith(DOT_XML)) {
end -= DOT_XML.length();
}
mDisplayName = mName.substring(begin, end).replace('_', ' ');
}
return mDisplayName;
}
public URL getURL() {
return mUrl;
}
/**
* Returns the icon image.
*
* @param width the width of the image
* @param height the height of the image
*/
@Nullable
public BufferedImage renderIcon(int width, int height) {
if (mVdTree == null) {
return null;
}
if (width <= 0 || height <= 0) {
width = Math.round(mVdTree.getBaseWidth());
height = Math.round(mVdTree.getBaseHeight());
}
BufferedImage image = AssetUtil.newArgbBufferedImage(width, height);
mVdTree.drawIntoImage(image);
return image;
}
@NonNull
private static VdTree parseVdTree(URL url) throws IOException {
return VdParser.parse(url.openStream(), null);
}
/**
* TODO: Merge this code back with GraphicsUtil in idea.
* Paints a checkered board style background. Each grid square is {@code cellSize} pixels.
*/
public static void paintCheckeredBackground(Graphics g, Color backgroundColor,
Color checkeredColor, Shape clip, int cellSize) {
final Shape savedClip = g.getClip();
((Graphics2D)g).clip(clip);
final Rectangle rect = clip.getBounds();
g.setColor(backgroundColor);
g.fillRect(rect.x, rect.y, rect.width, rect.height);
g.setColor(checkeredColor);
for (int dy = 0; dy * cellSize < rect.height; dy++) {
for (int dx = dy % 2; dx * cellSize < rect.width; dx += 2) {
g.fillRect(rect.x + dx * cellSize, rect.y + dy * cellSize, cellSize, cellSize);
}
}
g.setClip(savedClip);
}
@Override
public void paintIcon(Component c, Graphics gc, int x, int y) {
Graphics2D g = (Graphics2D)gc;
// Draw the checker board first, even when the tree is empty.
int width = c.getWidth();
int height = c.getHeight();
myRectangle.setBounds(0, 0, width, height);
if (mBackground != null) {
g.setColor(mBackground);
g.fillRect(myRectangle.x, myRectangle.y, myRectangle.width, myRectangle.height);
}
else if (mDrawCheckerBoardBackground) {
//noinspection UseJBColor
paintCheckeredBackground(g, Color.LIGHT_GRAY, CHECKER_COLOR, myRectangle, 8);
}
if (mVdTree == null) {
return;
}
// Show label below the icon?
if (mShowName) {
// Yes, so set aside space for the label
FontMetrics fontMetrics = c.getFontMetrics(c.getFont());
String displayName = getDisplayName();
Rectangle2D bounds = fontMetrics.getStringBounds(displayName, g);
height -= (bounds.getHeight() + LABEL_GAP);
int textX = Math.max(0, (int)(width - bounds.getWidth()) / 2);
int textY = height + LABEL_GAP;
final Shape prevClip = g.getClip();
g.clip(myRectangle);
g.setColor(c.getForeground());
// Setup text antialiasing:
g.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
//noinspection HardCodedStringLiteral
Map map = (Map) Toolkit.getDefaultToolkit().getDesktopProperty("awt.font.desktophints");
if (map != null) {
g.addRenderingHints(map);
} else {
g.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_LCD_HRGB);
}
g.drawString(displayName, textX, textY);
g.setClip(prevClip);
}
int minSize = Math.min(width, height);
BufferedImage image = AssetUtil.newArgbBufferedImage(minSize, minSize);
mVdTree.drawIntoImage(image);
image = adjustIconColor(c, image);
// Draw in the center of the component (we've already subtracted out the font height above if showing titles)
Rectangle rect = new Rectangle(0, 0, width, height);
AssetUtil.drawCenterInside(g, image, rect);
}
/**
* Adjust the icon color when the icon is intended to be painted on top of the given
* component. This method will convert the black icons to a light gray icon if it's being
* painted on a component with a dark background.
*
* @param component the component the icon is intended to be painted on top of
* @param image the icon image
* @return the converted image, or the original image if the background is light
*/
@NonNull
public static BufferedImage adjustIconColor(@NonNull Component component,
@NonNull BufferedImage image) {
Color background = component.getBackground();
if (background != null && background.getRed() < 128) {
ByteLookupTable table = new ByteLookupTable(0, COLOR_INVERSION_TABLE);
BufferedImageOp invertFilter = new LookupOp(table, null);
image = invertFilter.filter(image, null);
}
return image;
}
@Override
public int getIconWidth() {
return mWidth;
}
@Override
public int getIconHeight() {
return mHeight;
}
@Override
public int compareTo(@NonNull VdIcon other) {
return mName.compareTo(other.mName);
}
public void enableCheckerBoardBackground(boolean enable) {
mDrawCheckerBoardBackground = enable;
}
/**
* Whether we should show the title displayed below the image. When this is on, the icon is made
* smaller to fit the font height.
*/
public void setShowName(boolean showName) {
this.mShowName = showName;
}
}

View File

@ -0,0 +1,394 @@
/*
* 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 java.awt.geom.Path2D;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Given an array of {@link VdPath.Node}, generates a Path2D object.
* In other words, this is the engine which converts the pathData into
* a Path2D object, which is able to draw on Swing components.
* The logic and math here are the same as PathParser.java in framework.
*/
class VdNodeRender {
private static final Logger LOGGER = Logger.getLogger(VdNodeRender.class.getSimpleName());
public static void createPath(VdPath.Node[] nodes, Path2D path) {
float[] current = new float[6];
char lastCmd = ' ';
for (VdPath.Node node : nodes) {
addCommand(path, current, node.getType(), lastCmd, node.getParams());
lastCmd = node.getType();
}
}
private static void addCommand(Path2D path, float[] current, char cmd,
char lastCmd, float[] val) {
int incr = 2;
float cx = current[0];
float cy = current[1];
float cpx = current[2];
float cpy = current[3];
float loopX = current[4];
float loopY = current[5];
switch (cmd) {
case 'z':
case 'Z':
path.closePath();
cx = loopX;
cy = loopY;
//noinspection fallthrough
case 'm':
case 'M':
case 'l':
case 'L':
case 't':
case 'T':
incr = 2;
break;
case 'h':
case 'H':
case 'v':
case 'V':
incr = 1;
break;
case 'c':
case 'C':
incr = 6;
break;
case 's':
case 'S':
case 'q':
case 'Q':
incr = 4;
break;
case 'a':
case 'A':
incr = 7;
}
for (int k = 0; k < val.length; k += incr) {
boolean reflectCtrl;
float tempReflectedX, tempReflectedY;
switch (cmd) {
case 'm':
cx += val[k + 0];
cy += val[k + 1];
if (k > 0) {
// According to the spec, if a moveto is followed by multiple
// pairs of coordinates, the subsequent pairs are treated as
// implicit lineto commands.
path.lineTo(cx, cy);
} else {
path.moveTo(cx, cy);
loopX = cx;
loopY = cy;
}
break;
case 'M':
cx = val[k + 0];
cy = val[k + 1];
if (k > 0) {
// According to the spec, if a moveto is followed by multiple
// pairs of coordinates, the subsequent pairs are treated as
// implicit lineto commands.
path.lineTo(cx, cy);
} else {
path.moveTo(cx, cy);
loopX = cx;
loopY = cy;
}
break;
case 'l':
cx += val[k + 0];
cy += val[k + 1];
path.lineTo(cx, cy);
break;
case 'L':
cx = val[k + 0];
cy = val[k + 1];
path.lineTo(cx, cy);
break;
case 'z':
case 'Z':
path.closePath();
cx = loopX;
cy = loopY;
break;
case 'h':
cx += val[k + 0];
path.lineTo(cx, cy);
break;
case 'H':
path.lineTo(val[k + 0], cy);
cx = val[k + 0];
break;
case 'v':
cy += val[k + 0];
path.lineTo(cx, cy);
break;
case 'V':
path.lineTo(cx, val[k + 0]);
cy = val[k + 0];
break;
case 'c':
path.curveTo(cx + val[k + 0], cy + val[k + 1], cx + val[k + 2],
cy + val[k + 3], cx + val[k + 4], cy + val[k + 5]);
cpx = cx + val[k + 2];
cpy = cy + val[k + 3];
cx += val[k + 4];
cy += val[k + 5];
break;
case 'C':
path.curveTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3],
val[k + 4], val[k + 5]);
cx = val[k + 4];
cy = val[k + 5];
cpx = val[k + 2];
cpy = val[k + 3];
break;
case 's':
reflectCtrl = (lastCmd == 'c' || lastCmd == 's' || lastCmd == 'C' || lastCmd == 'S');
path.curveTo(reflectCtrl ? 2 * cx - cpx : cx, reflectCtrl ? 2
* cy - cpy : cy, cx + val[k + 0], cy + val[k + 1], cx
+ val[k + 2], cy + val[k + 3]);
cpx = cx + val[k + 0];
cpy = cy + val[k + 1];
cx += val[k + 2];
cy += val[k + 3];
break;
case 'S':
reflectCtrl = (lastCmd == 'c' || lastCmd == 's' || lastCmd == 'C' || lastCmd == 'S');
path.curveTo(reflectCtrl ? 2 * cx - cpx : cx, reflectCtrl ? 2
* cy - cpy : cy, val[k + 0], val[k + 1], val[k + 2],
val[k + 3]);
cpx = (val[k + 0]);
cpy = (val[k + 1]);
cx = val[k + 2];
cy = val[k + 3];
break;
case 'q':
path.quadTo(cx + val[k + 0], cy + val[k + 1], cx + val[k + 2],
cy + val[k + 3]);
cpx = cx + val[k + 0];
cpy = cy + val[k + 1];
// Note that we have to update cpx first, since cx will be updated here.
cx += val[k + 2];
cy += val[k + 3];
break;
case 'Q':
path.quadTo(val[k + 0], val[k + 1], val[k + 2], val[k + 3]);
cx = val[k + 2];
cy = val[k + 3];
cpx = val[k + 0];
cpy = val[k + 1];
break;
case 't':
reflectCtrl = (lastCmd == 'q' || lastCmd == 't' || lastCmd == 'Q' || lastCmd == 'T');
tempReflectedX = reflectCtrl ? 2 * cx - cpx : cx;
tempReflectedY = reflectCtrl ? 2 * cy - cpy : cy;
path.quadTo(tempReflectedX, tempReflectedY, cx + val[k + 0], cy + val[k + 1]);
cpx = tempReflectedX;
cpy = tempReflectedY;
cx += val[k + 0];
cy += val[k + 1];
break;
case 'T':
reflectCtrl = (lastCmd == 'q' || lastCmd == 't' || lastCmd == 'Q' || lastCmd == 'T');
tempReflectedX = reflectCtrl ? 2 * cx - cpx : cx;
tempReflectedY = reflectCtrl ? 2 * cy - cpy : cy;
path.quadTo(tempReflectedX, tempReflectedY, val[k + 0], val[k + 1]);
cx = val[k + 0];
cy = val[k + 1];
cpx = tempReflectedX;
cpy = tempReflectedY;
break;
case 'a':
// (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
drawArc(path, cx, cy, val[k + 5] + cx, val[k + 6] + cy,
val[k + 0], val[k + 1], val[k + 2], val[k + 3] != 0,
val[k + 4] != 0);
cx += val[k + 5];
cy += val[k + 6];
cpx = cx;
cpy = cy;
break;
case 'A':
drawArc(path, cx, cy, val[k + 5], val[k + 6], val[k + 0],
val[k + 1], val[k + 2], val[k + 3] != 0,
val[k + 4] != 0);
cx = val[k + 5];
cy = val[k + 6];
cpx = cx;
cpy = cy;
break;
}
lastCmd = cmd;
}
current[0] = cx;
current[1] = cy;
current[2] = cpx;
current[3] = cpy;
current[4] = loopX;
current[5] = loopY;
}
private static void drawArc(Path2D p, float x0, float y0, float x1,
float y1, float a, float b, float theta, boolean isMoreThanHalf,
boolean isPositiveArc) {
LOGGER.log(Level.FINE, "(" + x0 + "," + y0 + ")-(" + x1 + "," + y1
+ ") {" + a + " " + b + "}");
/* Convert rotation angle from degrees to radians */
double thetaD = theta * Math.PI / 180.0f;
/* Pre-compute rotation matrix entries */
double cosTheta = Math.cos(thetaD);
double sinTheta = Math.sin(thetaD);
/* Transform (x0, y0) and (x1, y1) into unit space */
/* using (inverse) rotation, followed by (inverse) scale */
double x0p = (x0 * cosTheta + y0 * sinTheta) / a;
double y0p = (-x0 * sinTheta + y0 * cosTheta) / b;
double x1p = (x1 * cosTheta + y1 * sinTheta) / a;
double y1p = (-x1 * sinTheta + y1 * cosTheta) / b;
LOGGER.log(Level.FINE, "unit space (" + x0p + "," + y0p + ")-(" + x1p
+ "," + y1p + ")");
/* Compute differences and averages */
double dx = x0p - x1p;
double dy = y0p - y1p;
double xm = (x0p + x1p) / 2;
double ym = (y0p + y1p) / 2;
/* Solve for intersecting unit circles */
double dsq = dx * dx + dy * dy;
if (dsq == 0.0) {
LOGGER.log(Level.FINE, " Points are coincident");
return; /* Points are coincident */
}
double disc = 1.0 / dsq - 1.0 / 4.0;
if (disc < 0.0) {
LOGGER.log(Level.FINE, "Points are too far apart " + dsq);
float adjust = (float) (Math.sqrt(dsq) / 1.99999);
drawArc(p, x0, y0, x1, y1, a * adjust, b * adjust, theta,
isMoreThanHalf, isPositiveArc);
return; /* Points are too far apart */
}
double s = Math.sqrt(disc);
double sdx = s * dx;
double sdy = s * dy;
double cx;
double cy;
if (isMoreThanHalf == isPositiveArc) {
cx = xm - sdy;
cy = ym + sdx;
} else {
cx = xm + sdy;
cy = ym - sdx;
}
double eta0 = Math.atan2((y0p - cy), (x0p - cx));
LOGGER.log(Level.FINE, "eta0 = Math.atan2( " + (y0p - cy) + " , "
+ (x0p - cx) + ") = " + Math.toDegrees(eta0));
double eta1 = Math.atan2((y1p - cy), (x1p - cx));
LOGGER.log(Level.FINE, "eta1 = Math.atan2( " + (y1p - cy) + " , "
+ (x1p - cx) + ") = " + Math.toDegrees(eta1));
double sweep = (eta1 - eta0);
if (isPositiveArc != (sweep >= 0)) {
if (sweep > 0) {
sweep -= 2 * Math.PI;
} else {
sweep += 2 * Math.PI;
}
}
cx *= a;
cy *= b;
double tcx = cx;
cx = cx * cosTheta - cy * sinTheta;
cy = tcx * sinTheta + cy * cosTheta;
LOGGER.log(
Level.FINE,
"cx, cy, a, b, x0, y0, thetaD, eta0, sweep = " + cx + " , "
+ cy + " , " + a + " , " + b + " , " + x0 + " , " + y0
+ " , " + Math.toDegrees(thetaD) + " , "
+ Math.toDegrees(eta0) + " , " + Math.toDegrees(sweep));
arcToBezier(p, cx, cy, a, b, x0, y0, thetaD, eta0, sweep);
}
/**
* Converts an arc to cubic Bezier segments and records them in p.
*
* @param p The target for the cubic Bezier segments
* @param cx The x coordinate center of the ellipse
* @param cy The y coordinate center of the ellipse
* @param a The radius of the ellipse in the horizontal direction
* @param b The radius of the ellipse in the vertical direction
* @param e1x E(eta1) x coordinate of the starting point of the arc
* @param e1y E(eta2) y coordinate of the starting point of the arc
* @param theta The angle that the ellipse bounding rectangle makes with the horizontal plane
* @param start The start angle of the arc on the ellipse
* @param sweep The angle (positive or negative) of the sweep of the arc on the ellipse
*/
private static void arcToBezier(Path2D p, double cx, double cy, double a,
double b, double e1x, double e1y, double theta, double start,
double sweep) {
// Taken from equations at:
// http://spaceroots.org/documents/ellipse/node8.html
// and http://www.spaceroots.org/documents/ellipse/node22.html
// Maximum of 45 degrees per cubic Bezier segment
int numSegments = (int) Math.ceil(Math.abs(sweep * 4 / Math.PI));
double eta1 = start;
double cosTheta = Math.cos(theta);
double sinTheta = Math.sin(theta);
double cosEta1 = Math.cos(eta1);
double sinEta1 = Math.sin(eta1);
double ep1x = (-a * cosTheta * sinEta1) - (b * sinTheta * cosEta1);
double ep1y = (-a * sinTheta * sinEta1) + (b * cosTheta * cosEta1);
double anglePerSegment = sweep / numSegments;
for (int i = 0; i < numSegments; i++) {
double eta2 = eta1 + anglePerSegment;
double sinEta2 = Math.sin(eta2);
double cosEta2 = Math.cos(eta2);
double e2x = cx + (a * cosTheta * cosEta2) - (b * sinTheta * sinEta2);
double e2y = cy + (a * sinTheta * cosEta2) + (b * cosTheta * sinEta2);
double ep2x = -a * cosTheta * sinEta2 - b * sinTheta * cosEta2;
double ep2y = -a * sinTheta * sinEta2 + b * cosTheta * cosEta2;
double tanDiff2 = Math.tan((eta2 - eta1) / 2);
double alpha = Math.sin(eta2 - eta1)
* (Math.sqrt(4 + (3 * tanDiff2 * tanDiff2)) - 1) / 3;
double q1x = e1x + alpha * ep1x;
double q1y = e1y + alpha * ep1y;
double q2x = e2x - alpha * ep2x;
double q2y = e2y - alpha * ep2y;
p.curveTo((float) q1x, (float) q1y, (float) q2x, (float) q2y, (float) e2x, (float) e2y);
eta1 = eta2;
e1x = e2x;
e1y = e2y;
ep1x = ep2x;
ep1y = ep2y;
}
}
}

View File

@ -0,0 +1,47 @@
// Copyright (C) 2017 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 java.awt.Color
/**
* Represents override information for VectorDrawable's XML file content.
*/
data class VdOverrideInfo(var width: Double, var height: Double, var tint: Color?, var alpha: Double,
var autoMirrored: Boolean) {
/** Checks if the width needs to be overridden. */
fun needsOverrideWidth(): Boolean {
return width > 0
}
/** Checks if the height needs to be overridden. */
fun needsOverrideHeight(): Boolean {
return height > 0
}
/** Checks if the alpha needs to be overridden. */
fun needsOverrideAlpha(): Boolean {
return 0 <= alpha && alpha < 1
}
/** Checks if the tint needs to be overridden. */
fun needsOverrideTint(): Boolean {
return tintRgb() != 0
}
/** Returns the RGB value of the tint. */
fun tintRgb(): Int {
return (tint?.rgb ?: 0) and 0xFFFFFF
}
}

View File

@ -0,0 +1,47 @@
/*
* 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.android.annotations.Nullable;
import com.android.utils.PositionXmlParser;
import java.io.IOException;
import java.io.InputStream;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
/**
* Parses a VectorDrawable's XML file, and generate an internal tree representation,
* which can be used for drawing / previewing.
*/
class VdParser {
// Note that the incoming file is an VectorDrawable's XML file, not an SVG.
@NonNull
public static VdTree parse(@NonNull InputStream stream, @Nullable StringBuilder vdErrorLog) {
final VdTree tree = new VdTree();
try {
Document doc = PositionXmlParser.parse(stream, true);
tree.parse(doc);
}
catch (ParserConfigurationException | SAXException | IOException e) {
if (vdErrorLog != null) {
vdErrorLog.append(e.getMessage());
}
}
return tree;
}
}

View File

@ -0,0 +1,849 @@
/*
* 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 static com.android.ide.common.vectordrawable.VdUtil.parseColorValue;
import static com.android.utils.DecimalUtils.trimInsignificantZeros;
import static com.android.utils.XmlUtils.formatFloatValue;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.ide.common.vectordrawable.PathParser.ParseMode;
import com.android.utils.PositionXmlParser;
import com.google.common.collect.ImmutableMap;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.LinearGradientPaint;
import java.awt.MultipleGradientPaint;
import java.awt.RadialGradientPaint;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.NodeList;
/** Represents one path element of a vector drawable. */
class VdPath extends VdElement {
private static final String PATH_ID = "android:name";
private static final String PATH_DESCRIPTION = "android:pathData";
private static final String PATH_FILL = "android:fillColor";
private static final String PATH_FILL_OPACITY = "android:fillAlpha";
private static final String PATH_FILL_TYPE = "android:fillType";
private static final String PATH_STROKE = "android:strokeColor";
private static final String PATH_STROKE_OPACITY = "android:strokeAlpha";
private static final String FILL_TYPE_EVEN_ODD = "evenOdd";
private static final String PATH_STROKE_WIDTH = "android:strokeWidth";
private static final String PATH_TRIM_START = "android:trimPathStart";
private static final String PATH_TRIM_END = "android:trimPathEnd";
private static final String PATH_TRIM_OFFSET = "android:trimPathOffset";
private static final String PATH_STROKE_LINE_CAP = "android:strokeLineCap";
private static final String PATH_STROKE_LINE_JOIN = "android:strokeLineJoin";
private static final String PATH_STROKE_MITER_LIMIT = "android:strokeMiterLimit";
private static final String LINE_CAP_BUTT = "butt";
private static final String LINE_CAP_ROUND = "round";
private static final String LINE_CAP_SQUARE = "square";
private static final String LINE_JOIN_MITER = "miter";
private static final String LINE_JOIN_ROUND = "round";
private static final String LINE_JOIN_BEVEL = "bevel";
private static final float EPSILON = 1e-6f;
private static final char INIT_TYPE = ' ';
private static final ImmutableMap<Character, Integer> COMMAND_STEP_MAP =
ImmutableMap.<Character, Integer>builder()
.put('z', 2)
.put('Z', 2)
.put('m', 2)
.put('M', 2)
.put('l', 2)
.put('L', 2)
.put('t', 2)
.put('T', 2)
.put('h', 1)
.put('H', 1)
.put('v', 1)
.put('V', 1)
.put('c', 6)
.put('C', 6)
.put('s', 4)
.put('S', 4)
.put('q', 4)
.put('Q', 4)
.put('a', 7)
.put('A', 7)
.build();
private VdGradient fillGradient;
private VdGradient strokeGradient;
private Node[] mNodeList;
private int mStrokeColor;
private int mFillColor;
private float mStrokeWidth;
private int mStrokeLineCap;
private int mStrokeLineJoin;
private float mStrokeMiterlimit = 4;
private float mStrokeAlpha = 1.0f;
private float mFillAlpha = 1.0f;
private int mFillType = PathIterator.WIND_NON_ZERO;
// TODO: support trim path.
private float mTrimPathStart;
private float mTrimPathEnd = 1;
private float mTrimPathOffset;
private void toPath(@NonNull Path2D path) {
path.reset();
if (mNodeList != null) {
VdNodeRender.createPath(mNodeList, path);
}
}
/**
* Represents one segment of the path data, e.g. "l 0,0 1,1".
*/
public static class Node {
private char mType;
private float[] mParams;
public char getType() {
return mType;
}
public float[] getParams() {
return mParams;
}
public Node(char type, @NonNull float[] params) {
this.mType = type;
this.mParams = params;
}
public Node(@NonNull Node n) {
this.mType = n.mType;
this.mParams = Arrays.copyOf(n.mParams, n.mParams.length);
}
public static boolean hasRelMoveAfterClose(@NonNull Node[] nodes) {
char preType = ' ';
for (Node n : nodes) {
if ((preType == 'z' || preType == 'Z') && n.mType == 'm') {
return true;
}
preType = n.mType;
}
return false;
}
@NonNull
public static String nodeListToString(@NonNull Node[] nodes, @NonNull NumberFormat format) {
StringBuilder result = new StringBuilder();
for (Node node : nodes) {
result.append(node.mType);
int len = node.mParams.length;
boolean implicitLineTo = false;
char lineToType = ' ';
if ((node.mType == 'm' || node.mType == 'M') && len > 2) {
implicitLineTo = true;
lineToType = node.mType == 'm' ? 'l' : 'L';
}
for (int j = 0; j < len; j++) {
if (j > 0) {
result.append(j % 2 != 0 ? "," : " ");
}
if (implicitLineTo && j == 2) {
result.append(lineToType);
}
float param = node.mParams[j];
if (!Float.isFinite(param)) {
throw new IllegalArgumentException("Invalid number: " + param);
}
String str = trimInsignificantZeros(format.format(param));
result.append(str);
}
}
return result.toString();
}
public static void transform(
@NonNull AffineTransform totalTransform, @NonNull Node[] nodes) {
Point2D.Float currentPoint = new Point2D.Float();
Point2D.Float currentSegmentStartPoint = new Point2D.Float();
char previousType = INIT_TYPE;
for (Node n : nodes) {
n.transform(totalTransform, currentPoint, currentSegmentStartPoint, previousType);
previousType = n.mType;
}
}
private void transform(
@NonNull AffineTransform totalTransform,
@NonNull Point2D.Float currentPoint,
@NonNull Point2D.Float currentSegmentStartPoint,
char previousType) {
// For horizontal and vertical lines, we have to convert to LineTo with 2 parameters.
// And for arcTo, we also need to isolate the parameters for transformation.
// Therefore looping will be necessary for such commands.
//
// Note that if the matrix is translation only, then we can save many computations.
int paramsLen = mParams.length;
float[] tempParams = new float[2 * paramsLen];
// These has to be pre-transformed value. In other words, the same as it is
// in the pathData.
float currentX = currentPoint.x;
float currentY = currentPoint.y;
float currentSegmentStartX = currentSegmentStartPoint.x;
float currentSegmentStartY = currentSegmentStartPoint.y;
int step = COMMAND_STEP_MAP.get(mType);
switch (mType) {
case 'z':
case 'Z':
currentX = currentSegmentStartX;
currentY = currentSegmentStartY;
break;
case 'M':
currentSegmentStartX = mParams[0];
currentSegmentStartY = mParams[1];
//noinspection fallthrough
case 'L':
case 'T':
case 'C':
case 'S':
case 'Q':
currentX = mParams[paramsLen - 2];
currentY = mParams[paramsLen - 1];
totalTransform.transform(mParams, 0, mParams, 0, paramsLen / 2);
break;
case 'm':
if (previousType == 'z' || previousType == 'Z') {
// Replace 'm' with 'M' to work around a bug in API 21 that is triggered
// when 'm' follows 'z'.
mType = 'M';
mParams[0] += currentSegmentStartX;
mParams[1] += currentSegmentStartY;
currentSegmentStartX = mParams[0]; // Start a new segment.
currentSegmentStartY = mParams[1];
for (int i = step; i < paramsLen; i += step) {
mParams[i] += mParams[i - step];
mParams[i + 1] += mParams[i + 1 - step];
}
currentX = mParams[paramsLen - 2];
currentY = mParams[paramsLen - 1];
totalTransform.transform(mParams, 0, mParams, 0, paramsLen / 2);
} else {
int headLen = 2;
currentX += mParams[0];
currentY += mParams[1];
currentSegmentStartX = currentX; // Start a new segment.
currentSegmentStartY = currentY;
if (previousType == INIT_TYPE) {
// 'm' at the start of a path is handled similar to 'M'.
// The coordinates are transformed as absolute.
totalTransform.transform(mParams, 0, mParams, 0, headLen / 2);
} else if (!isTranslationOnly(totalTransform)) {
deltaTransform(totalTransform, mParams, 0, headLen);
}
for (int i = headLen; i < paramsLen; i += step) {
currentX += mParams[i];
currentY += mParams[i + 1];
}
if (!isTranslationOnly(totalTransform)) {
deltaTransform(totalTransform, mParams, headLen, paramsLen - headLen);
}
}
break;
case 'l':
case 't':
case 'c':
case 's':
case 'q':
for (int i = 0; i < paramsLen - step + 1; i += step) {
currentX += mParams[i + step - 2];
currentY += mParams[i + step - 1];
}
if (!isTranslationOnly(totalTransform)) {
deltaTransform(totalTransform, mParams, 0, paramsLen);
}
break;
case 'H':
mType = 'L';
for (int i = 0; i < paramsLen; i++) {
tempParams[i * 2] = mParams[i];
tempParams[i * 2 + 1] = currentY;
currentX = mParams[i];
}
totalTransform.transform(tempParams, 0, tempParams, 0, paramsLen /*points*/);
mParams = tempParams;
break;
case 'V':
mType = 'L';
for (int i = 0; i < paramsLen; i++) {
tempParams[i * 2] = currentX;
tempParams[i * 2 + 1] = mParams[i];
currentY = mParams[i];
}
totalTransform.transform(tempParams, 0, tempParams, 0, paramsLen /*points*/);
mParams = tempParams;
break;
case 'h':
for (int i = 0; i < paramsLen; i++) {
currentX += mParams[i];
// tempParams may not be used but is assigned here to avoid a second loop.
tempParams[i * 2] = mParams[i];
tempParams[i * 2 + 1] = 0;
}
if (!isTranslationOnly(totalTransform)) {
mType = 'l';
deltaTransform(totalTransform, tempParams, 0, 2 * paramsLen);
mParams = tempParams;
}
break;
case 'v':
for (int i = 0; i < paramsLen; i++) {
// tempParams may not be used but is assigned here to avoid a second loop.
tempParams[i * 2] = 0;
tempParams[i * 2 + 1] = mParams[i];
currentY += mParams[i];
}
if (!isTranslationOnly(totalTransform)) {
mType = 'l';
deltaTransform(totalTransform, tempParams, 0, 2 * paramsLen);
mParams = tempParams;
}
break;
case 'A':
for (int i = 0; i < paramsLen - step + 1; i += step) {
// (0:rx 1:ry 2:x-axis-rotation 3:large-arc-flag 4:sweep-flag 5:x 6:y)
// [0, 1, 2]
if (!isTranslationOnly(totalTransform)) {
EllipseSolver ellipseSolver = new EllipseSolver(totalTransform,
currentX, currentY,
mParams[i], mParams[i + 1], mParams[i + 2],
mParams[i + 3], mParams[i + 4],
mParams[i + 5], mParams[i + 6]);
mParams[i] = ellipseSolver.getMajorAxis();
mParams[i + 1] = ellipseSolver.getMinorAxis();
mParams[i + 2] = ellipseSolver.getRotationDegree();
if (ellipseSolver.getDirectionChanged()) {
mParams[i + 4] = 1 - mParams[i + 4];
}
}
// [5, 6]
currentX = mParams[i + 5];
currentY = mParams[i + 6];
totalTransform.transform(mParams, i + 5, mParams, i + 5, 1 /*1 point only*/);
}
break;
case 'a':
for (int i = 0; i < paramsLen - step + 1; i += step) {
float oldCurrentX = currentX;
float oldCurrentY = currentY;
currentX += mParams[i + 5];
currentY += mParams[i + 6];
if (!isTranslationOnly(totalTransform)) {
EllipseSolver ellipseSolver = new EllipseSolver(totalTransform,
oldCurrentX, oldCurrentY,
mParams[i], mParams[i + 1], mParams[i + 2],
mParams[i + 3], mParams[i + 4],
oldCurrentX + mParams[i + 5],
oldCurrentY + mParams[i + 6]);
// (0:rx 1:ry 2:x-axis-rotation 3:large-arc-flag 4:sweep-flag 5:x 6:y)
// [5, 6]
deltaTransform(totalTransform, mParams, i + 5, 2);
// [0, 1, 2]
mParams[i] = ellipseSolver.getMajorAxis();
mParams[i + 1] = ellipseSolver.getMinorAxis();
mParams[i + 2] = ellipseSolver.getRotationDegree();
if (ellipseSolver.getDirectionChanged()) {
mParams[i + 4] = 1 - mParams[i + 4];
}
}
}
break;
default:
throw new IllegalStateException("Unexpected type " + mType);
}
currentPoint.setLocation(currentX, currentY);
currentSegmentStartPoint.setLocation(currentSegmentStartX, currentSegmentStartY);
}
private static boolean isTranslationOnly(@NonNull AffineTransform totalTransform) {
int type = totalTransform.getType();
return type == AffineTransform.TYPE_IDENTITY
|| type == AffineTransform.TYPE_TRANSLATION;
}
/**
* Applies delta transform to a set of points represented by a float array.
*
* @param totalTransform the transform to apply
* @param coordinates coordinates of points to apply the transform to
* @param offset in number of floats, not points
* @param paramsLen in number of floats, not points
*/
private static void deltaTransform(
@NonNull AffineTransform totalTransform,
@NonNull float[] coordinates,
int offset,
int paramsLen) {
double[] doubleArray = new double[paramsLen];
for (int i = 0; i < paramsLen; i++) {
doubleArray[i] = (double) coordinates[i + offset];
}
totalTransform.deltaTransform(doubleArray, 0, doubleArray, 0, paramsLen / 2);
for (int i = 0; i < paramsLen; i++) {
coordinates[i + offset] = (float) doubleArray[i];
}
}
@Override
@NonNull
public String toString() {
StringBuilder result = new StringBuilder();
result.append(mType);
int i = 0;
for (float param : this.mParams) {
result.append(i++ % 2 == 0 ? ' ' : ',');
result.append(formatFloatValue(param));
}
return result.toString();
}
}
private void setNameValue(@NonNull String name, @NonNull String value) {
if (value.startsWith(SdkConstants.PREFIX_RESOURCE_REF) && PATH_FILL.equals(name)) {
// Ignore the android resource in "android:fillColor" present in the new material icons.
value = "#000000";
}
if (value.startsWith(SdkConstants.PREFIX_RESOURCE_REF)) {
throw new ResourcesNotSupportedException(name, value);
}
if (PATH_DESCRIPTION.equals(name)) {
mNodeList = PathParser.parsePath(value, ParseMode.ANDROID);
} else if (PATH_ID.equals(name)) {
mName = value;
} else if (PATH_FILL.equals(name)) {
mFillColor = parseColorValue(value);
} else if (PATH_FILL_TYPE.equals(name)) {
mFillType = parseFillType(value);
} else if (PATH_STROKE.equals(name)) {
mStrokeColor = parseColorValue(value);
} else if (PATH_FILL_OPACITY.equals(name)) {
mFillAlpha = Float.parseFloat(value);
} else if (PATH_STROKE_OPACITY.equals(name)) {
mStrokeAlpha = Float.parseFloat(value);
} else if (PATH_STROKE_WIDTH.equals(name)) {
mStrokeWidth = Float.parseFloat(value);
} else if (PATH_TRIM_START.equals(name)) {
mTrimPathStart = Float.parseFloat(value);
} else if (PATH_TRIM_END.equals(name)) {
mTrimPathEnd = Float.parseFloat(value);
} else if (PATH_TRIM_OFFSET.equals(name)) {
mTrimPathOffset = Float.parseFloat(value);
} else if (PATH_STROKE_LINE_CAP.equals(name)) {
if (LINE_CAP_BUTT.equals(value)) {
mStrokeLineCap = 0;
} else if (LINE_CAP_ROUND.equals(value)) {
mStrokeLineCap = 1;
} else if (LINE_CAP_SQUARE.equals(value)) {
mStrokeLineCap = 2;
}
} else if (PATH_STROKE_LINE_JOIN.equals(name)) {
if (LINE_JOIN_MITER.equals(value)) {
mStrokeLineJoin = 0;
} else if (LINE_JOIN_ROUND.equals(value)) {
mStrokeLineJoin = 1;
} else if (LINE_JOIN_BEVEL.equals(value)) {
mStrokeLineJoin = 2;
}
} else if (PATH_STROKE_MITER_LIMIT.equals(name)) {
mStrokeMiterlimit = Float.parseFloat(value);
} else {
getLogger().log(Level.WARNING, ">>>>>> DID NOT UNDERSTAND ! \"" + name + "\" <<<<");
}
}
private static int parseFillType(@NonNull String value) {
if (FILL_TYPE_EVEN_ODD.equalsIgnoreCase(value)) {
return PathIterator.WIND_EVEN_ODD;
}
return PathIterator.WIND_NON_ZERO;
}
/** Multiplies the {@code alpha} value into the alpha channel {@code color}. */
protected static int applyAlpha(int color, float alpha) {
int alphaBytes = (color >> 24) & 0xff;
color &= 0x00FFFFFF;
color |= ((int) (alphaBytes * alpha)) << 24;
return color;
}
/** Draws the current path. */
@Override
public void draw(
@NonNull Graphics2D g,
@NonNull AffineTransform currentMatrix,
float scaleX,
float scaleY) {
Path2D path2d = new Path2D.Double(mFillType);
toPath(path2d);
// SWing operate the matrix is using pre-concatenate by default.
// Below is how this is handled in Android framework.
// pathMatrix.set(groupStackedMatrix);
// pathMatrix.postScale(scaleX, scaleY);
g.setTransform(new AffineTransform());
g.scale(scaleX, scaleY);
g.transform(currentMatrix);
if (mFillColor != 0 && fillGradient == null) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@SuppressWarnings("UseJBColor")
Color fillColor = new Color(applyAlpha(mFillColor, mFillAlpha), true);
g.setColor(fillColor);
g.fill(path2d);
}
if (mStrokeColor != 0 && mStrokeWidth != 0 && strokeGradient == null) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
BasicStroke stroke = new BasicStroke(mStrokeWidth, mStrokeLineCap, mStrokeLineJoin, mStrokeMiterlimit);
g.setStroke(stroke);
@SuppressWarnings("UseJBColor")
Color strokeColor = new Color(applyAlpha(mStrokeColor, mStrokeAlpha), true);
g.setColor(strokeColor);
g.draw(path2d);
}
if (isClipPath) {
Shape clip = g.getClip();
if (clip != null) {
Area area = new Area(clip);
area.add(new Area(path2d));
g.setClip(area);
} else {
g.setClip(path2d);
}
}
if (fillGradient != null) {
fillGradient.drawGradient(g, path2d, true);
}
if (strokeGradient != null) {
strokeGradient.drawGradient(g, path2d, false);
}
}
@Override
public void parseAttributes(@NonNull NamedNodeMap attributes) {
for (int i = 0; i < attributes.getLength(); i++) {
org.w3c.dom.Node attribute = attributes.item(i);
// See https://issuetracker.google.com/62052258 for why this check exists.
if (Objects.equals(attribute.getNamespaceURI(), SdkConstants.TOOLS_URI)) {
continue;
}
String name = attribute.getNodeName();
String value = attribute.getNodeValue();
setNameValue(name, value);
}
}
@Override
public boolean isGroup() {
return false;
}
@Override
@NonNull
public String toString() {
return "Path:" +
" Name: " + mName +
" Node: " + Arrays.toString(mNodeList) +
" mFillColor: " + Integer.toHexString(mFillColor) +
" mFillAlpha:" + mFillAlpha +
" mFillType:" + mFillType +
" mStrokeColor:" + Integer.toHexString(mStrokeColor) +
" mStrokeWidth:" + mStrokeWidth +
" mStrokeAlpha:" + mStrokeAlpha;
}
/**
* We parse the given node for the gradient information if it exists. If it contains a gradient,
* depending on what type, we set the fillGradient or strokeGradient of the current VdPath to a
* new VdGradient and add the gradient information.
*/
protected void addGradientIfExists(@NonNull org.w3c.dom.Node current) {
// This should be guaranteed to be the gradient given the way we are writing the VD XMLs.
org.w3c.dom.Node gradientNode = current.getFirstChild();
VdGradient newGradient = new VdGradient();
if (gradientNode != null) {
gradientNode = gradientNode.getNextSibling();
if (gradientNode != null) {
// This should also be guaranteed given the way we write the VD XMLs.
String attrValue = gradientNode.getAttributes().getNamedItem("name").getNodeValue();
if (attrValue.equals("android:fillColor")) {
fillGradient = newGradient;
} else if (attrValue.equals("android:strokeColor")) {
strokeGradient = newGradient;
}
gradientNode = gradientNode.getFirstChild();
if (gradientNode != null) {
gradientNode = gradientNode.getNextSibling();
}
}
}
if (gradientNode != null && gradientNode.getNodeName().equals("gradient")) {
NamedNodeMap gradientAttributes = gradientNode.getAttributes();
for (int i = 0; i < gradientAttributes.getLength(); i++) {
String name = gradientAttributes.item(i).getNodeName();
String value = gradientAttributes.item(i).getNodeValue();
newGradient.setGradientValue(name, value);
}
// Adding stop information to gradient.
NodeList items = gradientNode.getChildNodes();
for (int i = 0; i < items.getLength(); i++) {
org.w3c.dom.Node stop = items.item(i);
if (stop.getNodeName().equals("item")) {
NamedNodeMap stopAttr = stop.getAttributes();
String color = null;
String offset = null;
for (int j = 0; j < stopAttr.getLength(); j++) {
org.w3c.dom.Node currentItem = stopAttr.item(j);
if (currentItem.getNodeName().equals("android:color")) {
color = currentItem.getNodeValue();
if (color != null &&
(color.charAt(0) == '@' || color.charAt(0) == '?')) {
throw new IllegalVectorDrawableResourceRefException(
color, PositionXmlParser.getPosition(currentItem), null);
}
} else if (currentItem.getNodeName().equals("android:offset")) {
offset = currentItem.getNodeValue();
}
}
if (color == null) {
color = "#000000";
getLogger().log(Level.WARNING, ">>>>>> No color for gradient found >>>>>>");
}
if (offset == null) {
offset = "0";
getLogger().log(Level.WARNING, ">>>>>> No offset for gradient found>>>>>>");
}
GradientStop gradientStop = new GradientStop(color, offset);
newGradient.mGradientStops.add(gradientStop);
}
}
}
}
/**
* Contains gradient information in order to draw the fill or stroke of a path with a gradient.
*/
class VdGradient {
// Gradient attributes.
private float mStartX;
private float mStartY;
private float mEndX;
private float mEndY;
private float mCenterX;
private float mCenterY;
private float mGradientRadius;
private String mTileMode = "NO_CYCLE";
private String mGradientType = "linear";
private final ArrayList<GradientStop> mGradientStops = new ArrayList<>();
VdGradient() {}
private void setGradientValue(@NonNull String name, @NonNull String value) {
switch (name) {
case "android:type":
mGradientType = value;
break;
case "android:tileMode":
mTileMode = value;
break;
case "android:startX":
mStartX = Float.parseFloat(value);
break;
case "android:startY":
mStartY = Float.parseFloat(value);
break;
case "android:endX":
mEndX = Float.parseFloat(value);
break;
case "android:endY":
mEndY = Float.parseFloat(value);
break;
case "android:centerX":
mCenterX = Float.parseFloat(value);
break;
case "android:centerY":
mCenterY = Float.parseFloat(value);
break;
case "android:gradientRadius":
mGradientRadius = Float.parseFloat(value);
break;
}
}
private void drawGradient(@NonNull Graphics2D g, @NonNull Path2D path2d, boolean fill) {
if (mGradientStops.isEmpty()) {
return;
}
float[] mFractions = new float[mGradientStops.size()];
Color[] mGradientColors = new Color[mGradientStops.size()];
for (int j = 0; j < mGradientStops.size(); j++) {
GradientStop stop = mGradientStops.get(j);
float fraction = Float.parseFloat(stop.getOffset());
int colorInt = parseColorValue(stop.getColor());
//TODO: If opacity for android gradient items becomes supported, use mOpacity to modify colors.
@SuppressWarnings("UseJBColor")
Color color = new Color(colorInt, true);
mFractions[j] = fraction;
mGradientColors[j] = color;
}
// Gradient stop fractions must be strictly increasing in Java Swing. Increment the
// second of two equal fraction floats by a small amount to get the effect of two
// overlapping stops. When the fraction is the 1.0, then decrement accordingly.
// See LinearGradientPaint constructor:
// https://docs.oracle.com/javase/7/docs/api/java/awt/LinearGradientPaint.html
for (int i = 0; i < mGradientStops.size() - 1; i++) {
if (mFractions[i] >= mFractions[i + 1]) {
if (mFractions[i] + EPSILON <= 1.0f) {
mFractions[i + 1] = mFractions[i] + EPSILON;
}
}
}
for (int i = mGradientStops.size() - 2; i >= 0; i--) {
if (mFractions[i] >= mFractions[i + 1] && mFractions[i] >= 1.0f) {
mFractions[i] = mFractions[i + 1] - EPSILON;
} else {
break;
}
}
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// Create stroke in case the gradient applies to a stroke.
BasicStroke stroke =
new BasicStroke(
mStrokeWidth, mStrokeLineCap, mStrokeLineJoin, mStrokeMiterlimit);
// If there is only one stop, fill should be a solid color of the one stop.
if (mGradientStops.size() == 1) {
g.setColor(mGradientColors[0]);
if (!fill) {
g.setStroke(stroke);
}
g.draw(path2d);
} else {
MultipleGradientPaint.CycleMethod tile = MultipleGradientPaint.CycleMethod.NO_CYCLE;
if (mTileMode.equals("mirror")) {
tile = MultipleGradientPaint.CycleMethod.REFLECT;
} else if (mTileMode.equals("repeat")) {
tile = MultipleGradientPaint.CycleMethod.REPEAT;
}
if (mGradientType.equals("linear")) {
LinearGradientPaint gradient =
new LinearGradientPaint(
mStartX,
mStartY,
mEndX,
mEndY,
mFractions,
mGradientColors,
tile);
g.setPaint(gradient);
} else if (mGradientType.equals("radial")) {
RadialGradientPaint paint =
new RadialGradientPaint(
mCenterX,
mCenterY,
mGradientRadius,
mFractions,
mGradientColors,
tile);
g.setPaint(paint);
} else if (mGradientType.equals("sweep")) {
// AWT doesn't support sweep gradients but Android does.
getLogger().log(Level.WARNING,
">>>>>> Unable to render a sweep gradient."
+ " Using a solid color instead. >>>>>>");
g.setPaint(mGradientColors[0]);
} else {
getLogger().log(Level.WARNING,
">>>>>> Unsupported gradient type: \"" + mGradientType + "\">>>>>>");
}
if (fill) {
g.fill(path2d);
} else {
g.setStroke(stroke);
g.draw(path2d);
}
}
}
}
@NonNull
private static Logger getLogger() {
return Logger.getLogger(VdPath.class.getSimpleName());
}
}

View File

@ -0,0 +1,274 @@
/*
* 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 static com.google.common.math.DoubleMath.roundToInt;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.util.AssetUtil;
import com.android.utils.XmlUtils;
import com.google.common.base.Strings;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.math.RoundingMode;
import org.apache.xml.serialize.OutputFormat;
import org.apache.xml.serialize.XMLSerializer;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
/**
* Generates an image based on the VectorDrawable's XML content.
*/
public class VdPreview {
private static final String ANDROID_ALPHA = "android:alpha";
private static final String ANDROID_TINT = "android:tint";
private static final String ANDROID_AUTO_MIRRORED = "android:autoMirrored";
private static final String ANDROID_HEIGHT = "android:height";
private static final String ANDROID_WIDTH = "android:width";
private static final int MAX_PREVIEW_IMAGE_SIZE = 4096;
private static final int MIN_PREVIEW_IMAGE_SIZE = 1;
/**
* Encapsulates the information used to determine the preview image size. The reason we have
* different ways here is that both Studio UI and build process need to use this common code
* path to generate images for vector drawables. When {@code maxDimension} is not zero, use
* {@code maxDimension} as the maximum dimension value while keeping the aspect ratio.
* Otherwise, use {@code imageScale} to scale the image based on the XML's size information.
*/
public static class TargetSize {
private int imageMaxDimension;
private double imageScale;
private TargetSize(int maxDimension, double imageScale) {
this.imageMaxDimension = maxDimension;
this.imageScale = imageScale;
}
public static TargetSize createFromMaxDimension(int maxDimension) {
return new TargetSize(maxDimension, 0);
}
public static TargetSize createFromScale(double imageScale) {
return new TargetSize(0, imageScale);
}
}
/**
* Returns a format object for XML formatting.
*/
@NonNull
private static OutputFormat getPrettyPrintFormat() {
OutputFormat format = new OutputFormat();
format.setLineWidth(120);
format.setIndenting(true);
format.setIndent(4);
format.setEncoding("UTF-8");
format.setOmitComments(true);
format.setOmitXMLDeclaration(true);
return format;
}
/**
* The UI can override some properties of the Vector drawable. In order to override in
* an uniform way, we re-parse the XML file and pick the appropriate attributes to override.
*
* @param document the parsed document of original VectorDrawable's XML file.
* @param overrideInfo incoming override information for VectorDrawable.
* @param errorLog log for the parsing errors and warnings.
* @return the overridden XML, or null if exception happens or no attributes need to be
* overridden.
*/
@Nullable
public static String overrideXmlContent(
@NonNull Document document,
@NonNull VdOverrideInfo overrideInfo,
@Nullable StringBuilder errorLog) {
boolean contentChanged = false;
Element root = document.getDocumentElement();
// Update attributes, note that attributes as width and height are required,
// while others are optional.
if (overrideInfo.needsOverrideWidth()) {
if (setDimension(root, ANDROID_WIDTH, overrideInfo.getWidth())) {
contentChanged = true;
}
}
if (overrideInfo.needsOverrideHeight()) {
if (setDimension(root, ANDROID_HEIGHT, overrideInfo.getHeight())) {
contentChanged = true;
}
}
if (overrideInfo.needsOverrideAlpha()) {
String value = XmlUtils.formatFloatValue(overrideInfo.getAlpha());
if (setAttributeValue(root, ANDROID_ALPHA, value)) {
contentChanged = true;
}
}
if (overrideInfo.needsOverrideTint()) {
String value = String.format("#%06X", overrideInfo.tintRgb());
if (setAttributeValue(root, ANDROID_TINT, value)) {
contentChanged = true;
}
}
if (overrideInfo.getAutoMirrored()) {
if (setAttributeValue(root, ANDROID_AUTO_MIRRORED, "true")) {
contentChanged = true;
}
}
if (contentChanged) {
// Pretty-print the XML string from the document.
StringWriter stringOut = new StringWriter();
XMLSerializer serial = new XMLSerializer(stringOut, getPrettyPrintFormat());
try {
serial.serialize(document);
}
catch (IOException e) {
if (errorLog != null) {
errorLog.append("Exception while parsing XML file:\n").append(e.getMessage());
}
}
return stringOut.toString();
}
return null;
}
/**
* Sets value of a dimension attribute. The returned value reflects whether the value of
* the attribute was changed or not.
*/
private static boolean setDimension(
@NonNull Element element, @NonNull String attrName, double value) {
String newValue = XmlUtils.formatFloatValue(value) + "dp";
return setAttributeValue(element, attrName, newValue);
}
/**
* Sets value of an attribute. The returned value reflects whether the value of the attribute
* was changed or not.
*/
private static boolean setAttributeValue(
@NonNull Element element, @NonNull String attrName, @NonNull String value) {
String oldValue = element.getAttribute(attrName);
element.setAttribute(attrName, value);
return !value.equals(oldValue);
}
/**
* Generates an image according to the VectorDrawable's content {@code xmlFileContent}.
* At the same time, {@code vdErrorLog} captures all the errors found during parsing.
* The size of image is determined by the {@code size}.
*
* @param targetSize the size of result image.
* @param xmlFileContent VectorDrawable's XML file's content.
* @param errorLog log for the parsing errors and warnings.
* @return an preview image according to the VectorDrawable's XML
*/
@Nullable
public static BufferedImage getPreviewFromVectorXml(@NonNull TargetSize targetSize,
@Nullable String xmlFileContent,
@Nullable StringBuilder errorLog) {
if (Strings.isNullOrEmpty(xmlFileContent)) {
return null;
}
InputStream inputStream = new ByteArrayInputStream(xmlFileContent.getBytes(UTF_8));
VdTree vdTree = VdParser.parse(inputStream, errorLog);
return getPreviewFromVectorTree(targetSize, vdTree, errorLog);
}
/**
* This generates an image from a vector tree.
* The size of image is determined by the {@code size}.
*
* @param targetSize the size of result image.
* @param xml The vector drawable XML document
* @param vdErrorLog log for the errors and warnings.
* @return an preview image according to the VectorDrawable's XML
*/
@NonNull
public static BufferedImage getPreviewFromVectorDocument(@NonNull TargetSize targetSize,
@NonNull Document xml,
@Nullable StringBuilder vdErrorLog) {
VdTree vdTree = new VdTree();
vdTree.parse(xml);
return getPreviewFromVectorTree(targetSize, vdTree, vdErrorLog);
}
/**
* Generates an image from a vector tree. The size of image is determined by the {@code size}.
*
* @param targetSize the size of result image.
* @param vdTree The vector drawable
* @param errorLog log for the errors and warnings.
* @return an preview image according to the VectorDrawable's XML
*/
@NonNull
public static BufferedImage getPreviewFromVectorTree(@NonNull TargetSize targetSize,
@NonNull VdTree vdTree,
@Nullable StringBuilder errorLog) {
// If the forceImageSize is set (>0), then we honor that.
// Otherwise, we will ask the vector drawable for the prefer size, then apply the imageScale.
double vdWidth = vdTree.getBaseWidth();
double vdHeight = vdTree.getBaseHeight();
double imageWidth;
double imageHeight;
int forceImageSize = targetSize.imageMaxDimension;
double imageScale = targetSize.imageScale;
if (forceImageSize > 0) {
// The goal here is to generate an image within certain size, while preserving
// the aspect ratio as accurately as we can. If it is scaling too much to fit in,
// we log an error.
double maxVdSize = Math.max(vdWidth, vdHeight);
double ratioToForceImageSize = forceImageSize / maxVdSize;
double scaledWidth = ratioToForceImageSize * vdWidth;
double scaledHeight = ratioToForceImageSize * vdHeight;
imageWidth =
limitToInterval(scaledWidth, MIN_PREVIEW_IMAGE_SIZE, MAX_PREVIEW_IMAGE_SIZE);
imageHeight =
limitToInterval(scaledHeight, MIN_PREVIEW_IMAGE_SIZE, MAX_PREVIEW_IMAGE_SIZE);
if (errorLog != null && (scaledWidth != imageWidth || scaledHeight != imageHeight)) {
errorLog.append("Invalid image size, can't fit in a square whose size is ")
.append(forceImageSize);
}
} else {
imageWidth = vdWidth * imageScale;
imageHeight = vdHeight * imageScale;
}
// Create the image according to the vector drawable's aspect ratio.
BufferedImage image =
AssetUtil.newArgbBufferedImage(
roundToInt(imageWidth, RoundingMode.HALF_UP),
roundToInt(imageHeight, RoundingMode.HALF_UP));
vdTree.drawIntoImage(image);
return image;
}
private static double limitToInterval(double value, double begin, double end) {
return Math.max(begin, Math.min(end, value));
}
}

View File

@ -0,0 +1,225 @@
/*
* 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 static com.android.SdkConstants.TAG_CLIP_PATH;
import static com.android.SdkConstants.TAG_GROUP;
import static com.android.SdkConstants.TAG_PATH;
import static com.android.SdkConstants.TAG_VECTOR;
import static com.android.ide.common.vectordrawable.VdUtil.parseColorValue;
import com.android.annotations.NonNull;
import com.android.ide.common.util.AssetUtil;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Represents the whole VectorDrawable XML file's tree.
*/
class VdTree {
private static final Pattern ATTRIBUTE_PATTERN =
Pattern.compile("^\\s*(\\d+(\\.\\d+)*)\\s*([a-zA-Z]+)\\s*$");
private final VdGroup mRootGroup = new VdGroup();
private float mBaseWidth = 1;
private float mBaseHeight = 1;
private float mPortWidth = 1;
private float mPortHeight = 1;
private float mRootAlpha = 1;
private int mRootTint;
private static final boolean DBG_PRINT_TREE = false;
private static final String INDENT = " ";
float getBaseWidth(){
return mBaseWidth;
}
float getBaseHeight(){
return mBaseHeight;
}
float getPortWidth(){
return mPortWidth;
}
float getPortHeight(){
return mPortHeight;
}
private void drawTree(Graphics2D g, int w, int h) {
float scaleX = w / mPortWidth;
float scaleY = h / mPortHeight;
AffineTransform rootMatrix = new AffineTransform(); // identity
mRootGroup.draw(g, rootMatrix, scaleX, scaleY);
}
/** Draws the VdTree into an image. */
@SuppressWarnings("UseJBColor") // No need to use JBColor here.
public void drawIntoImage(@NonNull BufferedImage image) {
Graphics2D gFinal = (Graphics2D) image.getGraphics();
int width = image.getWidth();
int height = image.getHeight();
gFinal.setColor(new Color(255, 255, 255, 0));
gFinal.fillRect(0, 0, width, height);
if (mRootAlpha < 1.0) {
// Draw into a temporary image, then draw into the result image applying alpha blending.
BufferedImage alphaImage = AssetUtil.newArgbBufferedImage(width, height);
Graphics2D gTemp = (Graphics2D)alphaImage.getGraphics();
drawTree(gTemp, width, height);
gFinal.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, mRootAlpha));
gFinal.drawImage(alphaImage, 0, 0, null);
gTemp.dispose();
} else {
drawTree(gFinal, width, height);
}
if (mRootTint != 0) {
// Apply tint.
BufferedImage tintImage = AssetUtil.newArgbBufferedImage(width, height);
Graphics2D gTemp = (Graphics2D)tintImage.getGraphics();
gTemp.setPaint(new Color(mRootTint));
gTemp.fillRect(0, 0, width, height);
gFinal.setComposite(AlphaComposite.SrcIn);
try {
gFinal.drawImage(tintImage, gFinal.getTransform().createInverse(), null);
} catch (NoninvertibleTransformException ignored) {
}
gTemp.dispose();
}
gFinal.dispose();
}
public void parse(@NonNull Document doc) {
NodeList rootNodeList = doc.getElementsByTagName(TAG_VECTOR);
assert rootNodeList.getLength() == 1;
Node rootNode = rootNodeList.item(0);
parseRootNode(rootNode);
parseTree(rootNode, mRootGroup);
if (DBG_PRINT_TREE) {
debugPrintTree(0, mRootGroup);
}
}
private static void parseTree(@NonNull Node currentNode, @NonNull VdGroup currentGroup) {
NodeList childrenNodes = currentNode.getChildNodes();
int length = childrenNodes.getLength();
for (int i = 0; i < length; i++) {
Node child = childrenNodes.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
String name = child.getNodeName();
if (TAG_GROUP.equals(name)) {
VdGroup newGroup = parseGroupAttributes(child.getAttributes());
currentGroup.add(newGroup);
parseTree(child, newGroup);
} else if (TAG_PATH.equals(name)) {
VdPath newPath = parsePathAttributes(child.getAttributes());
newPath.addGradientIfExists(child);
currentGroup.add(newPath);
} else if (TAG_CLIP_PATH.equals(name)) {
VdPath newClipPath = parsePathAttributes(child.getAttributes());
newClipPath.setClipPath(true);
currentGroup.add(newClipPath);
}
}
}
}
private static void debugPrintTree(int level, @NonNull VdGroup mRootGroup) {
int len = mRootGroup.size();
if (len == 0) {
return;
}
StringBuilder prefixBuilder = new StringBuilder();
for (int i = 0; i < level; i++) {
prefixBuilder.append(INDENT);
}
String prefix = prefixBuilder.toString();
ArrayList<VdElement> children = mRootGroup.getChildren();
for (int i = 0; i < len; i++) {
VdElement child = children.get(i);
if (child.isGroup()) {
// TODO: print group info
debugPrintTree(level + 1, (VdGroup) child);
}
}
}
private void parseRootNode(@NonNull Node rootNode) {
if (rootNode.hasAttributes()) {
parseSize(rootNode.getAttributes());
}
}
private void parseSize(@NonNull NamedNodeMap attributes) {
int len = attributes.getLength();
for (int i = 0; i < len; i++) {
String name = attributes.item(i).getNodeName();
String value = attributes.item(i).getNodeValue();
Matcher matcher = ATTRIBUTE_PATTERN.matcher(value);
float size = 0;
if (matcher.matches()) {
size = Float.parseFloat(matcher.group(1));
}
// TODO: Extract dimension units like px etc. Right now all are treated as "dp".
if ("android:width".equals(name)) {
mBaseWidth = size;
} else if ("android:height".equals(name)) {
mBaseHeight = size;
} else if ("android:viewportWidth".equals(name)) {
mPortWidth = Float.parseFloat(value);
} else if ("android:viewportHeight".equals(name)) {
mPortHeight = Float.parseFloat(value);
} else if ("android:alpha".equals(name)) {
mRootAlpha = Float.parseFloat(value);
} else if ("android:tint".equals(name) && value.startsWith("#")) {
mRootTint = parseColorValue(value);
}
}
}
private static VdPath parsePathAttributes(@NonNull NamedNodeMap attributes) {
VdPath vgPath = new VdPath();
vgPath.parseAttributes(attributes);
return vgPath;
}
private static VdGroup parseGroupAttributes(@NonNull NamedNodeMap attributes) {
VdGroup vgGroup = new VdGroup();
vgGroup.parseAttributes(attributes);
return vgGroup;
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2017 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.
*/
@file:JvmName("VdUtil")
package com.android.ide.common.vectordrawable
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
/**
* Returns a [NumberFormat] of sufficient precision to use for formatting coordinate
* values given the maximum viewport dimension.
*/
fun getCoordinateFormat(maxViewportSize: Float): NumberFormat {
val exponent = Math.floor(Math.log10(maxViewportSize.toDouble())).toInt()
var fractionalDigits = 5 - exponent
val formatBuilder = StringBuilder("#")
if (fractionalDigits > 0) {
// Build a string with decimal places for "#.##...", and cap at 6 digits.
if (fractionalDigits > 6) {
fractionalDigits = 6
}
formatBuilder.append('.')
for (i in 0 until fractionalDigits) {
formatBuilder.append('#')
}
}
val fractionSeparator = DecimalFormatSymbols()
fractionSeparator.decimalSeparator = '.'
val format = DecimalFormat(formatBuilder.toString(), fractionSeparator)
format.roundingMode = RoundingMode.HALF_UP
return format
}
// Workaround for https://youtrack.jetbrains.com/issue/KT-4749
private const val ALPHA_MASK = 0xFF000000.toInt()
/**
* Parses a color value in #AARRGGBB format.
*
* @param color the color value string
* @return the integer color value
*/
fun parseColorValue(color: String): Int {
require(color.startsWith("#")) { "Invalid color value $color" }
return when (color.length) {
7 -> {
// #RRGGBB
Integer.parseUnsignedInt(color.substring(1), 16) or ALPHA_MASK
}
9 -> {
// #AARRGGBB
Integer.parseUnsignedInt(color.substring(1), 16)
}
4 -> {
// #RGB
val v = Integer.parseUnsignedInt(color.substring(1), 16)
var k = (v shr 8 and 0xF) * 0x110000
k = k or (v shr 4 and 0xF) * 0x1100
k = k or (v and 0xF) * 0x11
k or ALPHA_MASK
}
5 -> {
// #ARGB
val v = Integer.parseUnsignedInt(color.substring(1), 16)
var k = (v shr 12 and 0xF) * 0x11000000
k = k or (v shr 8 and 0xF) * 0x110000
k = k or (v shr 4 and 0xF) * 0x1100
k = k or (v and 0xF) * 0x11
k or ALPHA_MASK
}
else -> ALPHA_MASK
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
/**
* List of 'core' icons that will be added to the 'core' icons module, and depended on by
* material. These icons are the set of most commonly used icons, including icons used by
* Material components directly (such as the menu icon in an AppBar). All icons not specified
* here will be generated to the 'extended' icons module.
*/
val CoreIcons = setOf(
"Add", "Alert", "ArrowDown", "ArrowDownLeft", "ArrowDownload", "ArrowExpand", "ArrowForward", "ArrowLeft",
"ArrowRight", "ArrowSync", "ArrowUp", "ArrowUpLeft", "ArrowUpRight", "Attach", "Backspace", "CalendarLtr",
"CaretDown", "CaretDownRight", "CaretLeft", "CaretRight", "CaretUp", "Checkmark", "ChevronDown", "ChevronLeft",
"ChevronRight", "ChevronUp", "Clock", "Cloud", "Copy", "Cut", "Delete", "Dismiss", "Document", "Edit",
"ErrorCircle", "Eye", "Filter", "Flag", "Flash", "Folder", "Heart", "History", "Home", "Image", "Important", "Info",
"Key", "Link", "Mail", "Maximize", "MoreHorizontal", "MoreVertical", "Navigation", "Open", "Options", "Pause",
"Person", "Pin", "Play", "ReOrder", "Rename", "Save", "Search", "Send", "Settings", "Share", "Star", "Subtract",
"Tag", "Warning", "Wrench"
)

View File

@ -0,0 +1,35 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
/**
* Represents a icon's Kotlin name, processed XML file name, theme, and XML file content.
*
* The [kotlinName] is typically the PascalCase equivalent of the original icon name, with the
* caveat that icons starting with a number are prefixed with an underscore.
*
* @property kotlinName the name of the generated Kotlin property, for example `ZoomOutMap`.
* @property xmlFileName the name of the processed XML file
* @property theme the theme of this icon
* @property fileContent the content of the source XML file that will be parsed.
*/
data class Icon(
val kotlinName: String,
val xmlFileName: String,
val theme: IconTheme,
val fileContent: String
)

View File

@ -0,0 +1,132 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import androidx.compose.material.icons.generator.vector.FillType
import androidx.compose.material.icons.generator.vector.PathParser
import androidx.compose.material.icons.generator.vector.Vector
import androidx.compose.material.icons.generator.vector.VectorNode
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParser.END_DOCUMENT
import org.xmlpull.v1.XmlPullParser.END_TAG
import org.xmlpull.v1.XmlPullParser.START_TAG
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
/**
* Parser that converts [icon]s into [Vector]s
*/
class IconParser(private val icon: Icon) {
/**
* @return a [Vector] representing the provided [icon].
*/
fun parse(): Vector {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(icon.fileContent.byteInputStream(), null)
seekToStartTag()
}
check(parser.name == "vector") { "The start tag must be <vector>!" }
parser.next()
val nodes = mutableListOf<VectorNode>()
var currentGroup: VectorNode.Group? = null
while (!parser.isAtEnd()) {
when (parser.eventType) {
START_TAG -> {
when (parser.name) {
PATH -> {
val pathData = parser.getAttributeValue(
null,
PATH_DATA
)
val fillAlpha = parser.getValueAsFloat(FILL_ALPHA)
val strokeAlpha = parser.getValueAsFloat(STROKE_ALPHA)
val fillType = when (parser.getAttributeValue(null, FILL_TYPE)) {
// evenOdd and nonZero are the only supported values here, where
// nonZero is the default if no values are defined.
EVEN_ODD -> FillType.EvenOdd
else -> FillType.NonZero
}
val path = VectorNode.Path(
strokeAlpha = strokeAlpha ?: 1f,
fillAlpha = fillAlpha ?: 1f,
fillType = fillType,
nodes = PathParser.parsePathString(pathData)
)
if (currentGroup != null) {
currentGroup.paths.add(path)
} else {
nodes.add(path)
}
}
// Material icons are simple and don't have nested groups, so this can be simple
GROUP -> {
val group = VectorNode.Group()
currentGroup = group
nodes.add(group)
}
CLIP_PATH -> { /* TODO: b/147418351 - parse clipping paths */
}
}
}
}
parser.next()
}
return Vector(nodes)
}
}
/**
* @return the float value for the attribute [name], or null if it couldn't be found
*/
private fun XmlPullParser.getValueAsFloat(name: String) =
getAttributeValue(null, name)?.toFloatOrNull()
private fun XmlPullParser.seekToStartTag(): XmlPullParser {
var type = next()
while (type != START_TAG && type != END_DOCUMENT) {
// Empty loop
type = next()
}
if (type != START_TAG) {
throw XmlPullParserException("No start tag found")
}
return this
}
private fun XmlPullParser.isAtEnd() =
eventType == END_DOCUMENT || (depth < 1 && eventType == END_TAG)
// XML tag names
private const val CLIP_PATH = "clip-path"
private const val GROUP = "group"
private const val PATH = "path"
// XML attribute names
private const val PATH_DATA = "android:pathData"
private const val FILL_ALPHA = "android:fillAlpha"
private const val STROKE_ALPHA = "android:strokeAlpha"
private const val FILL_TYPE = "android:fillType"
// XML attribute values
private const val EVEN_ODD = "evenOdd"

View File

@ -0,0 +1,227 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import com.google.common.base.CaseFormat
import java.io.File
import java.util.Locale
/**
* Processes vector drawables in [iconDirectory] into a list of icons, removing any unwanted
* attributes (such as android: attributes that reference the theme) from the XML source.
*
* Drawables in [iconDirectory] should match the following structure, see download_material_icons.py
* to update icons, using this structure.
*
* // Top level
* [iconDirectory]
* // Theme name
* ├── filled
* // Icon name
* ├── menu.xml
* └── zoom_out_map.xml
* ├── outlined
* ├── rounded
* ├── twotone
* └── sharp
*
* @param iconDirectory root directory containing the directory structure mentioned above
* @param expectedApiFile location of the checked-in API file that contains the current list of
* all icons processed and generated
* @param generatedApiFile location of the to-be-generated API file in the build directory,
* that we will write to and compare with [expectedApiFile]. This way the generated file can be
* copied to overwrite the expected file, 'confirming' any API changes as a result of changing
* icons in [iconDirectory].
*/
class IconProcessor(
private val iconDirectories: List<File>,
private val expectedApiFile: File,
private val generatedApiFile: File,
private val verifyApi: Boolean = true
) {
/**
* @return a list of processed [Icon]s, from the given [iconDirectory].
*/
fun process(): List<Icon> {
val icons = loadIcons()
if (verifyApi) {
ensureIconsExistInAllThemes(icons)
writeApiFile(icons, generatedApiFile)
checkApi(expectedApiFile, generatedApiFile)
}
return icons
}
private fun loadIcons(): List<Icon> {
val themeDirs = iconDirectories
return themeDirs.flatMap { dir ->
/* val theme = dir.name.toIconTheme()
val icons = dir.walk().filter { !it.isDirectory }.toList()
val transformedIcons = icons.map { file ->
val filename = file.nameWithoutExtension
val kotlinName = filename.toKotlinPropertyName()
// Prefix the icon name with a theme so we can ensure they will be unique when
// copied to res/drawable.
val xmlName = "${theme.themePackageName}_$filename"
Icon(
kotlinName = kotlinName,
xmlFileName = xmlName,
theme = theme,
fileContent = processXmlFile(file.readText())
)
}*/
val icons = dir.walk().filter { !it.isDirectory }.toList()
val theme = when (dir.name) {
"regular" -> IconTheme.Regular
"filled" -> IconTheme.Filled
else -> IconTheme.Default
}
val transformedIcons = icons.map { file ->
val filename = file.nameWithoutExtension
val kotlinName = filename
// Prefix the icon name with a theme so we can ensure they will be unique when
// copied to res/drawable.
val xmlName = "${theme.themePackageName}_$filename"
println("Name: $kotlinName, Theme: $theme")
Icon(
kotlinName = kotlinName,
xmlFileName = xmlName,
theme = theme,
fileContent = processXmlFile(file.readText())
)
}
// Ensure icon names are unique when accounting for case insensitive filesystems -
// workaround for b/216295020
transformedIcons
.groupBy { it.kotlinName.lowercase(Locale.ROOT) }
.filter { it.value.size > 1 }
.filterNot { entry ->
entry.value.map { it.kotlinName }.containsAll(AllowedDuplicateIconNames)
}
.forEach { entry ->
throw IllegalStateException(
"""Found multiple icons with the same case-insensitive filename:
| ${entry.value.joinToString()}. Generating icons with the same
| case-insensitive filename will cause issues on devices without
| a case sensitive filesystem (OSX / Windows).""".trimMargin()
)
}
transformedIcons
}
}
}
/**
* Processes the given [fileContent] by removing android theme attributes and values.
*/
private fun processXmlFile(fileContent: String): String {
// Remove any defined tint for paths that use theme attributes
val tintAttribute = Regex.escape("""android:tint="?attr/colorControlNormal">""")
val tintRegex = """\n.*?$tintAttribute""".toRegex(RegexOption.MULTILINE)
return fileContent
.replace(tintRegex, ">")
// The imported icons have white as the default path color, so let's change it to be
// black as is typical on Android.
.replace("@android:color/white", "@android:color/black")
}
/**
* Ensures that each icon in each theme is available in every other theme
*/
private fun ensureIconsExistInAllThemes(icons: List<Icon>) {
val groupedIcons = icons.groupBy { it.theme }
check(groupedIcons.keys.containsAll(IconTheme.values().toList())) {
"Some themes were missing from the generated icons"
}
val expectedIconNames = groupedIcons.values.map { themeIcons ->
themeIcons.map { icon -> icon.kotlinName }.sorted()
}
expectedIconNames.first().let { expected ->
expectedIconNames.forEach { actual ->
check(actual == expected) {
"Not all icons were found in all themes $actual $expected"
}
}
}
}
/**
* Writes an API representation of [icons] to [file].
*/
private fun writeApiFile(icons: List<Icon>, file: File) {
val apiText = icons
.groupBy { it.theme }
.map { (theme, themeIcons) ->
themeIcons
.map { icon ->
theme.themeClassName + "." + icon.kotlinName
}
.sorted()
.joinToString(separator = "\n")
}
.sorted()
.joinToString(separator = "\n")
file.writeText(apiText)
}
/**
* Ensures that [generatedFile] matches the checked-in API surface in [expectedFile].
*/
private fun checkApi(expectedFile: File, generatedFile: File) {
check(expectedFile.exists()) {
"API file at ${expectedFile.canonicalPath} does not exist!"
}
check(expectedFile.readText() == generatedFile.readText()) {
"""Found differences when comparing API files!
|Please check the difference and copy over the changes if intended.
|expected file: ${expectedFile.canonicalPath}
|generated file: ${generatedFile.canonicalPath}
""".trimMargin()
}
}
/**
* Converts a snake_case name to a KotlinProperty name.
*
* If the first character of [this] is a digit, the resulting name will be prefixed with an `_`
*/
private fun String.toKotlinPropertyName(): String {
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, this).let { name ->
if (name.first().isDigit()) "_$name" else name
}
}
// These icons have already shipped in a stable release, so it is too late to rename / remove one to
// fix the clash.
private val AllowedDuplicateIconNames = listOf("AddChart", "Addchart")

View File

@ -0,0 +1,135 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.asClassName
import com.squareup.kotlinpoet.asTypeName
import com.squareup.kotlinpoet.buildCodeBlock
import java.io.File
import kotlin.reflect.KProperty0
/**
* Generates a list named `AllIcons` that contains pairs mapping a [KProperty0] of the generated
* icon to the name of the corresponding XML drawable. This is used so we can run tests comparing
* the generated icon against the original source drawable.
*
* @property icons the list of [Icon]s to generate the manifest from
*/
class IconTestingManifestGenerator(private val icons: List<Icon>) {
/**
* Generates the list and writes it to [outputSrcDirectory].
*/
fun generateTo(outputSrcDirectory: File) {
val propertyNames: MutableList<String> = mutableListOf()
// Split up this list by themes, otherwise we get a Method too large exception.
// We will then generate another file that returns the result of concatenating the list
// for each theme.
icons
.groupBy { it.theme }
.map { (theme, icons) ->
val propertyName = "${theme.themeClassName}Icons"
propertyNames += propertyName
theme to generateListOfIconsForTheme(propertyName, theme, icons)
}
.forEach { (theme, fileSpec) ->
// KotlinPoet bans wildcard imports, and we run into class compilation errors
// (too large a file?) if we add all the imports individually, so let's just add
// the imports to each file manually.
val wildcardImport =
"import androidx.compose.material.icons.${theme.themePackageName}.*"
fileSpec.writeToWithCopyright(outputSrcDirectory) { fileContent ->
fileContent.replace(
"import androidx.compose.ui.graphics.vector.ImageVector",
"$wildcardImport\n" +
"import androidx.compose.ui.graphics.vector.ImageVector"
)
}
}
val mainGetter = FunSpec.getterBuilder()
.addStatement("return " + propertyNames.joinToString(" + "))
.build()
FileSpec.builder(PackageNames.FluentIconsPackage.packageName, "AllIcons")
.addProperty(
PropertySpec.builder("AllIcons", type = listOfIconsType)
.getter(mainGetter)
.build()
).setIndent().build().writeToWithCopyright(outputSrcDirectory)
}
}
/**
* Generates a Kotlin file with a list containing all icons of the given [theme].
*
* @param propertyName the name of the top level property that we should generate the list under
* @param theme the theme that we are generating the file for
* @param allIcons a list containing all icons that we will filter to match [theme]
*/
private fun generateListOfIconsForTheme(
propertyName: String,
theme: IconTheme,
allIcons: List<Icon>
): FileSpec {
val icons = allIcons.filter { it.theme == theme }
val iconStatements = icons.toStatements()
return FileSpec.builder(PackageNames.FluentIconsPackage.packageName, propertyName)
.addProperty(
PropertySpec.builder(propertyName, type = listOfIconsType)
.initializer(
buildCodeBlock {
addStatement("listOf(")
indent()
iconStatements.forEach { add(it) }
unindent()
addStatement(")")
}
)
.build()
).setIndent().build()
}
/**
* @return a list of [CodeBlock] representing all the statements for the body of the list.
* For example, one statement would look like `(Icons.Filled::Menu) to menu`.
*/
private fun List<Icon>.toStatements(): List<CodeBlock> {
return mapIndexed { index, icon ->
buildCodeBlock {
val iconFunctionReference = "(%T.${icon.theme.themeClassName}::${icon.kotlinName})"
val text = "$iconFunctionReference to \"${icon.xmlFileName}\""
addStatement(if (index != size - 1) "$text," else text, ClassNames.Icons)
}
}
}
private val kPropertyType =
(KProperty0::class).asClassName().parameterizedBy(ClassNames.ImageVector)
private val pairType = (Pair::class).asClassName().parameterizedBy(
kPropertyType,
(String::class).asTypeName()
)
private val listOfIconsType = (List::class).asClassName().parameterizedBy(pairType)

View File

@ -0,0 +1,44 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
/**
* Enum representing the different themes for Material icons.
*
* @property themePackageName the lower case name used for package names and in xml files
* @property themeClassName the CameCase name used for the theme objects
*/
enum class IconTheme(val themePackageName: String, val themeClassName: String) {
Default("default", "Default"),
Regular("regular", "Regular"),
Filled("filled", "Filled")
}
/**
* Returns the matching [IconTheme] from [this] [IconTheme.themePackageName].
*/
fun String.toIconTheme() = requireNotNull(
IconTheme.values().find {
it.themePackageName == this
}
) { "No matching theme found" }
/**
* The ClassName representing this [IconTheme] object, so we can generate extension properties on
* the object.
*/
val IconTheme.className get() = PackageNames.FluentIconsPackage.className("Icons", themeClassName)

View File

@ -0,0 +1,53 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import java.io.File
/**
* Generates programmatic representation of all [icons] using [ImageVectorGenerator].
*
* @property icons the list of [Icon]s to generate Kotlin files for
*/
class IconWriter(private val icons: List<Icon>) {
/**
* Generates icons and writes them to [outputSrcDirectory], using [iconNamePredicate] to
* filter what icons to generate for.
*
* @param outputSrcDirectory the directory to generate source files in
* @param iconNamePredicate the predicate that filters what icons should be generated. If
* false, the icon will not be parsed and generated in [outputSrcDirectory].
*/
fun generateTo(
outputSrcDirectory: File,
iconNamePredicate: (String) -> Boolean
) {
icons.forEach { icon ->
if (!iconNamePredicate(icon.kotlinName)) return@forEach
val vector = IconParser(icon).parse()
val fileSpec = ImageVectorGenerator(
icon.kotlinName,
icon.theme,
vector
).createFileSpec()
fileSpec.writeToWithCopyright(outputSrcDirectory)
}
}
}

View File

@ -0,0 +1,182 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import androidx.compose.material.icons.generator.vector.FillType
import androidx.compose.material.icons.generator.vector.Vector
import androidx.compose.material.icons.generator.vector.VectorNode
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.buildCodeBlock
import java.util.Locale
/**
* Generator for creating a Kotlin source file with a ImageVectror property for the given [vector],
* with name [iconName] and theme [iconTheme].
*
* @param iconName the name for the generated property, which is also used for the generated file.
* I.e if the name is `Menu`, the property will be `Menu` (inside a theme receiver object) and
* the file will be `Menu.kt` (under the theme package name).
* @param iconTheme the theme that this vector belongs to. Used to scope the property to the
* correct receiver object, and also for the package name of the generated file.
* @param vector the parsed vector to generate ImageVector.Builder commands for
*/
class ImageVectorGenerator(
private val iconName: String,
private val iconTheme: IconTheme,
private val vector: Vector
) {
/**
* @return a [FileSpec] representing a Kotlin source file containing the property for this
* programmatic [vector] representation.
*
* The package name and hence file location of the generated file is:
* [PackageNames.FluentIconsPackage] + [IconTheme.themePackageName].
*/
fun createFileSpec(): FileSpec {
val iconsPackage = PackageNames.FluentIconsPackage.packageName
val themePackage = iconTheme.themePackageName
val combinedPackageName = "$iconsPackage.$themePackage"
// Use a unique property name for the private backing property. This is because (as of
// Kotlin 1.4) each property with the same name will be considered as a possible candidate
// for resolution, regardless of the access modifier, so by using unique names we reduce
// the size from ~6000 to 1, and speed up compilation time for these icons.
val backingPropertyName = "_" + iconName.replaceFirstChar { it.lowercase(Locale.ROOT) }
val backingProperty = backingProperty(name = backingPropertyName)
return FileSpec.builder(
packageName = combinedPackageName,
fileName = iconName
).addProperty(
PropertySpec.builder(name = iconName, type = ClassNames.ImageVector)
.receiver(iconTheme.className)
.getter(iconGetter(backingProperty, iconName, iconTheme))
.build()
).addProperty(
backingProperty
).setIndent().build()
}
/**
* @return the body of the getter for the icon property. This getter returns the backing
* property if it is not null, otherwise creates the icon and 'caches' it in the backing
* property, and then returns the backing property.
*/
private fun iconGetter(
backingProperty: PropertySpec,
iconName: String,
iconTheme: IconTheme
): FunSpec {
return FunSpec.getterBuilder()
.addCode(
buildCodeBlock {
beginControlFlow("if (%N != null)", backingProperty)
addStatement("return %N!!", backingProperty)
endControlFlow()
}
)
.addCode(
buildCodeBlock {
beginControlFlow(
"%N = %M(name = \"%N.%N\")",
backingProperty,
MemberNames.FluentIcon,
iconTheme.name,
iconName
)
vector.nodes.forEach { node -> addRecursively(node) }
endControlFlow()
}
)
.addStatement("return %N!!", backingProperty)
.build()
}
/**
* @return The private backing property that is used to cache the ImageVector for a given
* icon once created.
*
* @param name the name of this property
*/
private fun backingProperty(name: String): PropertySpec {
val nullableImageVector = ClassNames.ImageVector.copy(nullable = true)
return PropertySpec.builder(name = name, type = nullableImageVector)
.mutable()
.addModifiers(KModifier.PRIVATE)
.initializer("null")
.build()
}
}
/**
* Recursively adds function calls to construct the given [vectorNode] and its children.
*/
private fun CodeBlock.Builder.addRecursively(vectorNode: VectorNode) {
when (vectorNode) {
// TODO: b/147418351 - add clip-paths once they are supported
is VectorNode.Group -> {
beginControlFlow("%M", MemberNames.Group)
vectorNode.paths.forEach { path ->
addRecursively(path)
}
endControlFlow()
}
is VectorNode.Path -> {
addPath(vectorNode) {
vectorNode.nodes.forEach { pathNode ->
addStatement(pathNode.asFunctionCall())
}
}
}
}
}
/**
* Adds a function call to create the given [path], with [pathBody] containing the commands for
* the path.
*/
private fun CodeBlock.Builder.addPath(
path: VectorNode.Path,
pathBody: CodeBlock.Builder.() -> Unit
) {
// Only set the fill type if it is EvenOdd - otherwise it will just be the default.
val setFillType = path.fillType == FillType.EvenOdd
val parameterList = with(path) {
listOfNotNull(
"fillAlpha = ${fillAlpha}f".takeIf { fillAlpha != 1f },
"strokeAlpha = ${strokeAlpha}f".takeIf { strokeAlpha != 1f },
"pathFillType = %M".takeIf { setFillType }
)
}
val parameters = if (parameterList.isNotEmpty()) {
parameterList.joinToString(prefix = "(", postfix = ")")
} else {
""
}
if (setFillType) {
beginControlFlow("%M$parameters", MemberNames.FluentPath, MemberNames.EvenOdd)
} else {
beginControlFlow("%M$parameters", MemberNames.FluentPath)
}
pathBody()
endControlFlow()
}

View File

@ -0,0 +1,91 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import com.squareup.kotlinpoet.FileSpec
import java.io.File
import java.nio.file.Files
import java.text.SimpleDateFormat
import java.util.Date
/**
* Writes the given [FileSpec] to [directory], appending a copyright notice to the beginning.
* This is needed as this functionality isn't supported in KotlinPoet natively, and is not
* intended to be supported. https://github.com/square/kotlinpoet/pull/514#issuecomment-441397363
*
* @param directory directory to write this [FileSpec] to
* @param textTransform optional transformation to apply to the source file before writing to disk
*/
fun FileSpec.writeToWithCopyright(directory: File, textTransform: ((String) -> String)? = null) {
var outputDirectory = directory
if (packageName.isNotEmpty()) {
for (packageComponent in packageName.split('.').dropLastWhile { it.isEmpty() }) {
outputDirectory = outputDirectory.resolve(packageComponent)
}
}
Files.createDirectories(outputDirectory.toPath())
val file = outputDirectory.resolve("$name.kt")
// Write this FileSpec to a StringBuilder, so we can process the text before writing to file.
val fileContent = StringBuilder().run {
writeTo(this)
toString()
}
val transformedText = textTransform?.invoke(fileContent) ?: fileContent
file.writeText(copyright + "\n\n" + transformedText)
}
/**
* Sets the indent for this [FileSpec] to match that of our code style.
*/
fun FileSpec.Builder.setIndent() = indent(Indent)
// Code style indent is 4 spaces, compared to KotlinPoet's default of 2
private val Indent = " ".repeat(4)
/**
* AOSP copyright notice. Given that we generate this code every build, it is never checked in,
* so we should update the copyright with the current year every time we write to disk.
*/
/* private val copyright
get() = """
/*
* Copyright $currentYear 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.
*/
""".trimIndent() */
private val copyright
get() = ""
private val currentYear: String get() = SimpleDateFormat("yyyy").format(Date())

View File

@ -0,0 +1,54 @@
/*
* Copyright 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 androidx.compose.material.icons.generator
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.MemberName
/**
* Package names used for icon generation.
*/
enum class PackageNames(val packageName: String) {
FluentIconsPackage("com.konyaco.fluent.icons"),
GraphicsPackage("androidx.compose.ui.graphics"),
VectorPackage(GraphicsPackage.packageName + ".vector")
}
/**
* [ClassName]s used for icon generation.
*/
object ClassNames {
val Icons = PackageNames.FluentIconsPackage.className("Icons")
val ImageVector = PackageNames.VectorPackage.className("ImageVector")
val PathFillType = PackageNames.GraphicsPackage.className("PathFillType", "Companion")
}
/**
* [MemberName]s used for icon generation.
*/
object MemberNames {
val FluentIcon = MemberName(PackageNames.FluentIconsPackage.packageName, "fluentIcon")
val FluentPath = MemberName(PackageNames.FluentIconsPackage.packageName, "fluentPath")
val EvenOdd = MemberName(ClassNames.PathFillType, "EvenOdd")
val Group = MemberName(PackageNames.VectorPackage.packageName, "group")
}
/**
* @return the [ClassName] of the given [classNames] inside this package.
*/
fun PackageNames.className(vararg classNames: String) = ClassName(this.packageName, *classNames)

View File

@ -0,0 +1,31 @@
# Material Iconography
## Modules / components
Material iconography is split across these modules:
1. The `generator` module, in `generator/` - this module processes and generates Kotlin source files as part of the build step of the other modules. This module is not shipped as an artifact, and caches its outputs based on the input icons (found in `generator/raw-icons`).
2. `material-icons-core` , in `core/` - this module contains _core_ icons, the set of most-commonly-used icons used by applications, including the icons that are required by Material components themselves, such as the menu icon. This module is fairly small and is depended on by `material`.
3. `material-icons-extended`, in `extended/` - this module contains every icon that is not in `material-icons-core`, and has a transitive `api` dependency on `material-icons-core`, so depending on this module will provide every single Material icon (over 5000 at the time of writing). Due to the excessive size of this module, this module should ***NOT*** be included as a direct dependency of any other library, and should only be used if Proguard / R8 is enabled.
4. `material-icons-extended-$theme`, in `extended/` - these modules each contain a specific theme from material-icons-extended, to facilitate compiling the icon soure files more quickly in parallel
## Icon Generation
Generation is split into a few distinct steps:
1. Icons are downloaded (manually) using the Google Fonts API, using the script in the `generator` module. This downloads vector drawables for every single Material icon to the `raw-icons` folder.
2. During compilation of the core and extended modules, these icons are processed to remove theme attributes that we cannot generate code for, checked to ensure that all icons exist in all themes, and then an API tracking file similar to API files in other modules is generated. This API file tracks what icons we have processed / will generate code, and the build will fail at this point if there are differences between the checked in API file and the generated API file.
3. Once these icons are processed, we then parse each file, create a Vector-like representation, and convert this to `VectorAssetBuilder` commands that during run time will create a matching source code representation of this XML file. We then write this generated Kotlin file to the output directory, where it will be compiled as part of the `core` / `extended` module's source code, as if it was manually written and checked in. Each XML file creates a corresponding Kotlin file, containing a `by lazy` property representing that icon. For more information on using the generated icons, see `androidx.compose.material.icons.Icons`.
## Adding new icons
To add new icons, simply use the icon downloading script at `generator/download_material_icons.py`, run any Gradle command that will trigger compilation of the icon modules (such as `./gradlew buildOnServer`), and follow the message in the build failure asking to confirm API changes by updating the API tracking file.
## Icon Testing
Similar to how we generate Kotlin source for each icon, we also generate a 'testing manifest' that contains a list of all the source drawables, matched to their generated code representations. This allows us to run screenshot comparison tests (`IconComparisonTest`) that compare each pixel of the generated and source drawables, to ensure we generated the correct code, and that any changes in parsing logic that causes inconsistencies with our generation logic is caught in CI.
## Useful files
- `generator/download_material_icons.py` - script to download icons from Google Fonts API
- `IconGenerationTask` - base Gradle task for generating icons / associated testing resources as part of the build. See subclasses for specific task logic.
- `IconProcessor` - processes raw XML files in `generator/raw-icons`, creates a list of all icons that we will generate source for and ensures the API surface of these icons has not changed. (We do not run Metalava (or lint) on the extended module due to the extreme size of the module (5000+ source files) - running Metalava here would take hours.)
- `IconParser` - simple XML parser that parses the processed XML files into Vector representations.
- `VectorAssetGenerator` - converts icons parsed by `IconParser` into Kotlin source files that represent the icon.

View File

@ -0,0 +1,28 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.vector
/**
* Determines the winding rule that decides how the interior of a [VectorNode.Path] is calculated.
*
* This maps to [android.graphics.Path.FillType] used in the framework, and can be defined in XML
* via `android:fillType`.
*/
enum class FillType {
NonZero,
EvenOdd
}

View File

@ -0,0 +1,453 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.vector
/**
* Class representing a singular path command in a vector.
*
* @property isCurve whether this command is a curve command
* @property isQuad whether this command is a quad command
*/
/* ktlint-disable max-line-length */
sealed class PathNode(val isCurve: Boolean = false, val isQuad: Boolean = false) {
/**
* Maps a [PathNode] to a string representing an invocation of the corresponding PathBuilder
* function to add this node to the builder.
*/
abstract fun asFunctionCall(): String
// RelativeClose and Close are considered the same internally, so we represent both with Close
// for simplicity and to make equals comparisons robust.
object Close : PathNode() {
override fun asFunctionCall() = "close()"
}
data class RelativeMoveTo(val x: Float, val y: Float) : PathNode() {
override fun asFunctionCall() = "moveToRelative(${x}f, ${y}f)"
}
data class MoveTo(val x: Float, val y: Float) : PathNode() {
override fun asFunctionCall() = "moveTo(${x}f, ${y}f)"
}
data class RelativeLineTo(val x: Float, val y: Float) : PathNode() {
override fun asFunctionCall() = "lineToRelative(${x}f, ${y}f)"
}
data class LineTo(val x: Float, val y: Float) : PathNode() {
override fun asFunctionCall() = "lineTo(${x}f, ${y}f)"
}
data class RelativeHorizontalTo(val x: Float) : PathNode() {
override fun asFunctionCall() = "horizontalLineToRelative(${x}f)"
}
data class HorizontalTo(val x: Float) : PathNode() {
override fun asFunctionCall() = "horizontalLineTo(${x}f)"
}
data class RelativeVerticalTo(val y: Float) : PathNode() {
override fun asFunctionCall() = "verticalLineToRelative(${y}f)"
}
data class VerticalTo(val y: Float) : PathNode() {
override fun asFunctionCall() = "verticalLineTo(${y}f)"
}
data class RelativeCurveTo(
val dx1: Float,
val dy1: Float,
val dx2: Float,
val dy2: Float,
val dx3: Float,
val dy3: Float
) : PathNode(isCurve = true) {
override fun asFunctionCall() = "curveToRelative(${dx1}f, ${dy1}f, ${dx2}f, ${dy2}f, ${dx3}f, ${dy3}f)"
}
data class CurveTo(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float,
val x3: Float,
val y3: Float
) : PathNode(isCurve = true) {
override fun asFunctionCall() = "curveTo(${x1}f, ${y1}f, ${x2}f, ${y2}f, ${x3}f, ${y3}f)"
}
data class RelativeReflectiveCurveTo(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float
) : PathNode(isCurve = true) {
override fun asFunctionCall() = "reflectiveCurveToRelative(${x1}f, ${y1}f, ${x2}f, ${y2}f)"
}
data class ReflectiveCurveTo(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float
) : PathNode(isCurve = true) {
override fun asFunctionCall() = "reflectiveCurveTo(${x1}f, ${y1}f, ${x2}f, ${y2}f)"
}
data class RelativeQuadTo(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float
) : PathNode(isQuad = true) {
override fun asFunctionCall() = "quadToRelative(${x1}f, ${y1}f, ${x2}f, ${y2}f)"
}
data class QuadTo(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float
) : PathNode(isQuad = true) {
override fun asFunctionCall() = "quadTo(${x1}f, ${y1}f, ${x2}f, ${y2}f)"
}
data class RelativeReflectiveQuadTo(
val x: Float,
val y: Float
) : PathNode(isQuad = true) {
override fun asFunctionCall() = "reflectiveQuadToRelative(${x}f, ${y}f)"
}
data class ReflectiveQuadTo(
val x: Float,
val y: Float
) : PathNode(isQuad = true) {
override fun asFunctionCall() = "reflectiveQuadTo(${x}f, ${y}f)"
}
data class RelativeArcTo(
val horizontalEllipseRadius: Float,
val verticalEllipseRadius: Float,
val theta: Float,
val isMoreThanHalf: Boolean,
val isPositiveArc: Boolean,
val arcStartDx: Float,
val arcStartDy: Float
) : PathNode() {
override fun asFunctionCall() = "arcToRelative(${horizontalEllipseRadius}f, ${verticalEllipseRadius}f, ${theta}f, $isMoreThanHalf, $isPositiveArc, ${arcStartDx}f, ${arcStartDy}f)"
}
data class ArcTo(
val horizontalEllipseRadius: Float,
val verticalEllipseRadius: Float,
val theta: Float,
val isMoreThanHalf: Boolean,
val isPositiveArc: Boolean,
val arcStartX: Float,
val arcStartY: Float
) : PathNode() {
override fun asFunctionCall() = "arcTo(${horizontalEllipseRadius}f, ${verticalEllipseRadius}f, ${theta}f, $isMoreThanHalf, $isPositiveArc, ${arcStartX}f, ${arcStartY}f)"
}
}
/* ktlint-enable max-line-length */
/**
* Return the corresponding [PathNode] for the given character key if it exists.
* If the key is unknown then [IllegalArgumentException] is thrown
* @return [PathNode] that matches the key
* @throws IllegalArgumentException
*/
internal fun Char.toPathNodes(args: FloatArray): List<PathNode> = when (this) {
RelativeCloseKey, CloseKey -> listOf(
PathNode.Close
)
RelativeMoveToKey ->
pathNodesFromArgs(
args,
NUM_MOVE_TO_ARGS
) { array ->
PathNode.RelativeMoveTo(
x = array[0],
y = array[1]
)
}
MoveToKey ->
pathNodesFromArgs(
args,
NUM_MOVE_TO_ARGS
) { array ->
PathNode.MoveTo(
x = array[0],
y = array[1]
)
}
RelativeLineToKey ->
pathNodesFromArgs(
args,
NUM_LINE_TO_ARGS
) { array ->
PathNode.RelativeLineTo(
x = array[0],
y = array[1]
)
}
LineToKey ->
pathNodesFromArgs(
args,
NUM_LINE_TO_ARGS
) { array ->
PathNode.LineTo(
x = array[0],
y = array[1]
)
}
RelativeHorizontalToKey ->
pathNodesFromArgs(
args,
NUM_HORIZONTAL_TO_ARGS
) { array ->
PathNode.RelativeHorizontalTo(
x = array[0]
)
}
HorizontalToKey ->
pathNodesFromArgs(
args,
NUM_HORIZONTAL_TO_ARGS
) { array ->
PathNode.HorizontalTo(x = array[0])
}
RelativeVerticalToKey ->
pathNodesFromArgs(
args,
NUM_VERTICAL_TO_ARGS
) { array ->
PathNode.RelativeVerticalTo(y = array[0])
}
VerticalToKey ->
pathNodesFromArgs(
args,
NUM_VERTICAL_TO_ARGS
) { array ->
PathNode.VerticalTo(y = array[0])
}
RelativeCurveToKey ->
pathNodesFromArgs(
args,
NUM_CURVE_TO_ARGS
) { array ->
PathNode.RelativeCurveTo(
dx1 = array[0],
dy1 = array[1],
dx2 = array[2],
dy2 = array[3],
dx3 = array[4],
dy3 = array[5]
)
}
CurveToKey ->
pathNodesFromArgs(
args,
NUM_CURVE_TO_ARGS
) { array ->
PathNode.CurveTo(
x1 = array[0],
y1 = array[1],
x2 = array[2],
y2 = array[3],
x3 = array[4],
y3 = array[5]
)
}
RelativeReflectiveCurveToKey ->
pathNodesFromArgs(
args,
NUM_REFLECTIVE_CURVE_TO_ARGS
) { array ->
PathNode.RelativeReflectiveCurveTo(
x1 = array[0],
y1 = array[1],
x2 = array[2],
y2 = array[3]
)
}
ReflectiveCurveToKey ->
pathNodesFromArgs(
args,
NUM_REFLECTIVE_CURVE_TO_ARGS
) { array ->
PathNode.ReflectiveCurveTo(
x1 = array[0],
y1 = array[1],
x2 = array[2],
y2 = array[3]
)
}
RelativeQuadToKey ->
pathNodesFromArgs(
args,
NUM_QUAD_TO_ARGS
) { array ->
PathNode.RelativeQuadTo(
x1 = array[0],
y1 = array[1],
x2 = array[2],
y2 = array[3]
)
}
QuadToKey ->
pathNodesFromArgs(
args,
NUM_QUAD_TO_ARGS
) { array ->
PathNode.QuadTo(
x1 = array[0],
y1 = array[1],
x2 = array[2],
y2 = array[3]
)
}
RelativeReflectiveQuadToKey ->
pathNodesFromArgs(
args,
NUM_REFLECTIVE_QUAD_TO_ARGS
) { array ->
PathNode.RelativeReflectiveQuadTo(
x = array[0],
y = array[1]
)
}
ReflectiveQuadToKey ->
pathNodesFromArgs(
args,
NUM_REFLECTIVE_QUAD_TO_ARGS
) { array ->
PathNode.ReflectiveQuadTo(
x = array[0],
y = array[1]
)
}
RelativeArcToKey ->
pathNodesFromArgs(
args,
NUM_ARC_TO_ARGS
) { array ->
PathNode.RelativeArcTo(
horizontalEllipseRadius = array[0],
verticalEllipseRadius = array[1],
theta = array[2],
isMoreThanHalf = array[3].compareTo(0.0f) != 0,
isPositiveArc = array[4].compareTo(0.0f) != 0,
arcStartDx = array[5],
arcStartDy = array[6]
)
}
ArcToKey ->
pathNodesFromArgs(
args,
NUM_ARC_TO_ARGS
) { array ->
PathNode.ArcTo(
horizontalEllipseRadius = array[0],
verticalEllipseRadius = array[1],
theta = array[2],
isMoreThanHalf = array[3].compareTo(0.0f) != 0,
isPositiveArc = array[4].compareTo(0.0f) != 0,
arcStartX = array[5],
arcStartY = array[6]
)
}
else -> throw IllegalArgumentException("Unknown command for: $this")
}
private inline fun pathNodesFromArgs(
args: FloatArray,
numArgs: Int,
nodeFor: (subArray: FloatArray) -> PathNode
): List<PathNode> {
return (0..args.size - numArgs step numArgs).map { index ->
val subArray = args.slice(index until index + numArgs).toFloatArray()
val node = nodeFor(subArray)
when {
// According to the spec, if a MoveTo is followed by multiple pairs of coordinates,
// the subsequent pairs are treated as implicit corresponding LineTo commands.
node is PathNode.MoveTo && index > 0 -> PathNode.LineTo(
subArray[0],
subArray[1]
)
node is PathNode.RelativeMoveTo && index > 0 ->
PathNode.RelativeLineTo(
subArray[0],
subArray[1]
)
else -> node
}
}
}
/**
* Constants used by [Char.toPathNodes] for creating [PathNode]s from parsed paths.
*/
private const val RelativeCloseKey = 'z'
private const val CloseKey = 'Z'
private const val RelativeMoveToKey = 'm'
private const val MoveToKey = 'M'
private const val RelativeLineToKey = 'l'
private const val LineToKey = 'L'
private const val RelativeHorizontalToKey = 'h'
private const val HorizontalToKey = 'H'
private const val RelativeVerticalToKey = 'v'
private const val VerticalToKey = 'V'
private const val RelativeCurveToKey = 'c'
private const val CurveToKey = 'C'
private const val RelativeReflectiveCurveToKey = 's'
private const val ReflectiveCurveToKey = 'S'
private const val RelativeQuadToKey = 'q'
private const val QuadToKey = 'Q'
private const val RelativeReflectiveQuadToKey = 't'
private const val ReflectiveQuadToKey = 'T'
private const val RelativeArcToKey = 'a'
private const val ArcToKey = 'A'
/**
* Constants for the number of expected arguments for a given node. If the number of received
* arguments is a multiple of these, the excess will be converted into additional path nodes.
*/
private const val NUM_MOVE_TO_ARGS = 2
private const val NUM_LINE_TO_ARGS = 2
private const val NUM_HORIZONTAL_TO_ARGS = 1
private const val NUM_VERTICAL_TO_ARGS = 1
private const val NUM_CURVE_TO_ARGS = 6
private const val NUM_REFLECTIVE_CURVE_TO_ARGS = 4
private const val NUM_QUAD_TO_ARGS = 4
private const val NUM_REFLECTIVE_QUAD_TO_ARGS = 2
private const val NUM_ARC_TO_ARGS = 7

View File

@ -0,0 +1,174 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.vector
import kotlin.math.min
/**
* Trimmed down copy of PathParser that doesn't handle interacting with Paths, and only is
* responsible for parsing path strings.
*/
object PathParser {
/**
* Parses the path string to create a collection of PathNode instances with their corresponding
* arguments
* throws an IllegalArgumentException or NumberFormatException if the parameters are invalid
*/
fun parsePathString(pathData: String): List<PathNode> {
val nodes = mutableListOf<PathNode>()
fun addNode(cmd: Char, args: FloatArray) {
nodes.addAll(cmd.toPathNodes(args))
}
var start = 0
var end = 1
while (end < pathData.length) {
end = nextStart(pathData, end)
val s = pathData.substring(start, end).trim { it <= ' ' }
if (s.isNotEmpty()) {
val args = getFloats(s)
addNode(s[0], args)
}
start = end
end++
}
if (end - start == 1 && start < pathData.length) {
addNode(pathData[start], FloatArray(0))
}
return nodes
}
private fun nextStart(s: String, end: Int): Int {
var index = end
var c: Char
while (index < s.length) {
c = s[index]
// Note that 'e' or 'E' are not valid path commands, but could be
// used for floating point numbers' scientific notation.
// Therefore, when searching for next command, we should ignore 'e'
// and 'E'.
if (((c - 'A') * (c - 'Z') <= 0 || (c - 'a') * (c - 'z') <= 0) &&
c != 'e' && c != 'E'
) {
return index
}
index++
}
return index
}
@Throws(NumberFormatException::class)
private fun getFloats(s: String): FloatArray {
if (s[0] == 'z' || s[0] == 'Z') {
return FloatArray(0)
}
val results = FloatArray(s.length)
var count = 0
var startPosition = 1
var endPosition: Int
val result =
ExtractFloatResult()
val totalLength = s.length
// The startPosition should always be the first character of the
// current number, and endPosition is the character after the current
// number.
while (startPosition < totalLength) {
extract(s, startPosition, result)
endPosition = result.endPosition
if (startPosition < endPosition) {
results[count++] = java.lang.Float.parseFloat(
s.substring(startPosition, endPosition)
)
}
startPosition = if (result.endWithNegativeOrDot) {
// Keep the '-' or '.' sign with next number.
endPosition
} else {
endPosition + 1
}
}
return copyOfRange(results, 0, count)
}
private fun copyOfRange(original: FloatArray, start: Int, end: Int): FloatArray {
if (start > end) {
throw IllegalArgumentException()
}
val originalLength = original.size
if (start < 0 || start > originalLength) {
throw ArrayIndexOutOfBoundsException()
}
val resultLength = end - start
val copyLength = min(resultLength, originalLength - start)
val result = FloatArray(resultLength)
original.copyInto(result, 0, start, start + copyLength)
return result
}
private fun extract(s: String, start: Int, result: ExtractFloatResult) {
// Now looking for ' ', ',', '.' or '-' from the start.
var currentIndex = start
var foundSeparator = false
result.endWithNegativeOrDot = false
var secondDot = false
var isExponential = false
while (currentIndex < s.length) {
val isPrevExponential = isExponential
isExponential = false
when (s[currentIndex]) {
' ', ',' -> foundSeparator = true
'-' ->
// The negative sign following a 'e' or 'E' is not a separator.
if (currentIndex != start && !isPrevExponential) {
foundSeparator = true
result.endWithNegativeOrDot = true
}
'.' ->
if (!secondDot) {
secondDot = true
} else {
// This is the second dot, and it is considered as a separator.
foundSeparator = true
result.endWithNegativeOrDot = true
}
'e', 'E' -> isExponential = true
}
if (foundSeparator) {
break
}
currentIndex++
}
// When there is nothing found, then we put the end position to the end
// of the string.
result.endPosition = currentIndex
}
private data class ExtractFloatResult(
// We need to return the position of the next separator and whether the
// next float starts with a '-' or a '.'.
var endPosition: Int = 0,
var endWithNegativeOrDot: Boolean = false
)
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.vector
/**
* Simplified representation of a vector, with root [nodes].
*
* [nodes] may either be a singleton list of the root group, or a list of root paths / groups if
* there are multiple top level declaration.
*/
class Vector(val nodes: List<VectorNode>)
/**
* Simplified vector node representation, as the total set of properties we need to care about
* for Material icons is very limited.
*/
sealed class VectorNode {
class Group(val paths: MutableList<Path> = mutableListOf()) : VectorNode()
class Path(
val strokeAlpha: Float,
val fillAlpha: Float,
val fillType: FillType,
val nodes: List<PathNode>
) : VectorNode()
}

View File

@ -0,0 +1,23 @@
package generator
import androidx.compose.material.icons.generator.CoreIcons
import androidx.compose.material.icons.generator.IconProcessor
import androidx.compose.material.icons.generator.IconWriter
fun main() {
val icons = IconProcessor(
listOf(baseDir.resolve("icon_output_xml/filled"), baseDir.resolve("icon_output_xml/regular")),
baseDir.resolve("expApis.txt"),
baseDir.resolve("genApis.txt"),
false
).process()
val outDir = baseDir.resolve("icon_output_kt_core").also {
it.mkdirs()
}
IconWriter(icons).generateTo(outDir) { it in CoreIcons }
val extendedDir = baseDir.resolve("icon_output_kt_extended").also {
it.mkdirs()
}
IconWriter(icons).generateTo(extendedDir) { it !in CoreIcons }
}

View File

@ -0,0 +1,44 @@
package generator
import com.android.ide.common.vectordrawable.Svg2Vector
fun main() {
val outDir = baseDir.resolve("icon_output_xml").also { it.mkdirs() }
val filled = outDir.resolve("filled").also { it.mkdirs() }
val regular = outDir.resolve("regular").also { it.mkdirs() }
val error = mutableListOf<String>()
baseDir.resolve("icon_output").listFiles()!!.forEach { file ->
val filename = file.nameWithoutExtension
val theme = when {
filename.endsWith("Regular") -> "regular"
filename.endsWith("Filled") -> "filled"
else -> error("Unknown theme")
}
val outputDir = when (theme) {
"regular" -> regular
"filled" -> filled
else -> regular
}
val size =
filename.substring(filename.length - theme.length - 2, filename.length - theme.length)
val iconName = filename.substringBeforeLast(size)
println(iconName)
val outputFile = outputDir.resolve("$iconName.xml")
outputFile.outputStream().use { stream ->
Svg2Vector.parseSvgToXml(file, stream)
}
if (outputFile.length() == 0L) {
error.add(filename)
System.err.println("Error: $filename")
outputFile.delete()
}
}
error.forEach {
println("Error: $it")
}
}

View File

@ -0,0 +1,25 @@
package generator
import org.jsoup.Jsoup
fun main() {
val html = baseDir.resolve("icons-catalog.html").readText()
val document = Jsoup.parse(html)
val outputDir = baseDir.resolve("icon_output").also {
it.mkdirs()
}
document.select(".sbdocs-content > div:last-child > div > div").asSequence().map {
val name = it.select("code")[0]!!.text()
val svg = it.select("svg")[0].toString()
name to svg
}.forEach { (name, svg) ->
println(svg)
val converted = svg.replace(Regex("""\sclass="[\s\S]+?""""), "")
.replace(Regex("""\saria-hidden="true""""), "")
.replace(" fill=\"currentColor\"", "")
.replace("viewbox", "viewBox")
println(converted)
outputDir.resolve("$name.svg").writeText(converted)
}
}

View File

@ -0,0 +1,5 @@
package generator
import java.io.File
val baseDir = File("fluent-icons-generator")

View File

@ -0,0 +1,243 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.tasks
import androidx.compose.material.icons.generator.Icon
import androidx.compose.material.icons.generator.IconProcessor
import com.android.build.gradle.LibraryExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import java.io.File
import java.util.Locale
/**
* Base [org.gradle.api.Task] for tasks relating to icon generation.
*/
@CacheableTask
abstract class IconGenerationTask : DefaultTask() {
/**
* Directory containing raw drawables. These icons will be processed to generate programmatic
* representations.
*/
@PathSensitive(PathSensitivity.RELATIVE)
@InputDirectory
val allIconsDirectory =
project.rootProject.project(GeneratorProject).projectDir.resolve("raw-icons")
/**
* Specific theme to generate icons for, or null to generate all
*/
@Optional
@Input
var themeName: String? = null
/**
* Specific icon directories to use in this task
*/
@Internal
fun getIconDirectories(): List<File> {
val themeName = themeName
if (themeName != null) {
return listOf(allIconsDirectory.resolve(themeName))
} else {
return allIconsDirectory.listFiles()!!.filter { it.isDirectory }
}
}
/**
* Checked-in API file for the generator module, where we will track all the generated icons
*/
@PathSensitive(PathSensitivity.NONE)
@InputFile
val expectedApiFile =
project.rootProject.project(GeneratorProject).projectDir.resolve("api/icons.txt")
/**
* Root build directory for this task, where outputs will be placed into.
*/
@OutputDirectory
lateinit var buildDirectory: File
/**
* Generated API file that will be placed in the build directory. This can be copied manually
* to [expectedApiFile] to confirm that API changes were intended.
*/
@get:OutputFile
val generatedApiFile: File
get() = buildDirectory.resolve("api/icons.txt")
/**
* @return a list of all processed [Icon]s from [getIconDirectories].
*/
fun loadIcons(): List<Icon> {
// material-icons-core loads and verifies all of the icons from all of the themes:
// both that all icons are present in all themes, and also that no icons have been removed.
// So, when we're loading just one theme, we don't need to verify it
val verifyApi = themeName == null
return IconProcessor(
getIconDirectories(),
expectedApiFile,
generatedApiFile,
verifyApi
).process()
}
@get:OutputDirectory
val generatedSrcMainDirectory: File
get() = buildDirectory.resolve(GeneratedSrcMain)
@get:OutputDirectory
val generatedSrcAndroidTestDirectory: File
get() = buildDirectory.resolve(GeneratedSrcAndroidTest)
@get:OutputDirectory
val generatedResourceDirectory: File
get() = buildDirectory.resolve(GeneratedResource)
/**
* The action for this task
*/
@TaskAction
abstract fun run()
companion object {
/**
* Registers the core [project]. The core project contains only the icons defined in
* [androidx.compose.material.icons.generator.CoreIcons], and no tests.
*/
@JvmStatic
fun registerCoreIconProject(
project: Project,
libraryExtension: LibraryExtension,
isMpp: Boolean
) {
if (isMpp) {
CoreIconGenerationTask.register(project, null)
} else {
libraryExtension.libraryVariants.all { variant ->
CoreIconGenerationTask.register(project, variant)
}
}
}
/**
* Registers the extended [project]. The core project contains all icons except for the
* icons defined in [androidx.compose.material.icons.generator.CoreIcons], as well as a
* bitmap comparison test for every icon in both the core and extended project.
*/
@JvmStatic
fun registerExtendedIconThemeProject(
project: Project,
libraryExtension: LibraryExtension,
isMpp: Boolean
) {
if (isMpp) {
ExtendedIconGenerationTask.register(project, null)
} else {
libraryExtension.libraryVariants.all { variant ->
ExtendedIconGenerationTask.register(project, variant)
}
}
// b/175401659 - disable lint as it takes a long time, and most errors should
// be caught by lint on material-icons-core anyway
project.afterEvaluate {
project.tasks.named("lintAnalyzeDebug") { t ->
t.enabled = false
}
project.tasks.named("lintDebug") { t ->
t.enabled = false
}
}
}
@JvmStatic
fun registerExtendedIconMainProject(
project: Project,
libraryExtension: LibraryExtension
) {
libraryExtension.testVariants.all { variant ->
IconTestingGenerationTask.register(project, variant)
}
}
const val GeneratedSrcMain = "src/commonMain/kotlin"
const val GeneratedSrcAndroidTest = "src/androidAndroidTest/kotlin"
const val GeneratedResource = "generatedIcons/res"
}
}
// Path to the generator project
private const val GeneratorProject = ":compose:material:material:icons:generator"
/**
* Registers a new [T] in [this], and sets [IconGenerationTask.buildDirectory] depending on
* [variant].
*
* @param variant the [com.android.build.gradle.api.BaseVariant] to associate this task with, or
* `null` if this task does not change between variants.
* @return a [Pair] of the created [TaskProvider] of [T] of [IconGenerationTask], and the [File]
* for the directory that files will be generated to
*/
@Suppress("DEPRECATION") // BaseVariant
fun <T : IconGenerationTask> Project.registerGenerationTask(
taskName: String,
taskClass: Class<T>,
variant: com.android.build.gradle.api.BaseVariant? = null
): Pair<TaskProvider<T>, File> {
val variantName = variant?.name ?: "allVariants"
val themeName = if (project.name.contains("material-icons-extended-")) {
project.name.replace("material-icons-extended-", "")
} else {
null
}
val buildDirectory = project.buildDir.resolve("generatedIcons/$variantName")
return tasks.register("$taskName${variantName.capitalize(Locale.getDefault())}", taskClass) {
it.themeName = themeName
it.buildDirectory = buildDirectory
} to buildDirectory
}
fun Project.getMultiplatformSourceSet(name: String): KotlinSourceSet {
val sourceSet = project.multiplatformExtension!!.sourceSets.find { it.name == name }
return requireNotNull(sourceSet) {
"No source sets found matching $name"
}
}
private val Project.multiplatformExtension
get() = extensions.findByType(KotlinMultiplatformExtension::class.java)

View File

@ -0,0 +1,164 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.tasks
import androidx.compose.material.icons.generator.CoreIcons
import androidx.compose.material.icons.generator.IconWriter
import org.gradle.api.Project
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.bundling.Jar
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import java.io.File
/**
* Task responsible for converting core icons from xml to a programmatic representation.
*/
@CacheableTask
open class CoreIconGenerationTask : IconGenerationTask() {
override fun run() =
IconWriter(loadIcons()).generateTo(generatedSrcMainDirectory) { it in CoreIcons }
companion object {
/**
* Registers [CoreIconGenerationTask] in [project].
*/
@Suppress("DEPRECATION") // BaseVariant
fun register(project: Project, variant: com.android.build.gradle.api.BaseVariant? = null) {
val (task, buildDirectory) = project.registerGenerationTask(
"generateCoreIcons",
CoreIconGenerationTask::class.java,
variant
)
// Multiplatform
if (variant == null) {
registerIconGenerationTask(project, task, buildDirectory)
}
// AGP
else variant.registerIconGenerationTask(project, task, buildDirectory)
}
}
}
/**
* Task responsible for converting extended icons from xml to a programmatic representation.
*/
@CacheableTask
open class ExtendedIconGenerationTask : IconGenerationTask() {
override fun run() =
IconWriter(loadIcons()).generateTo(generatedSrcMainDirectory) { it !in CoreIcons }
companion object {
/**
* Registers [ExtendedIconGenerationTask] in [project]. (for use with mpp)
*/
@Suppress("DEPRECATION") // BaseVariant
fun register(project: Project, variant: com.android.build.gradle.api.BaseVariant? = null) {
val (task, buildDirectory) = project.registerGenerationTask(
"generateExtendedIcons",
ExtendedIconGenerationTask::class.java,
variant
)
// Multiplatform
if (variant == null) {
registerIconGenerationTask(project, task, buildDirectory)
}
// AGP
else variant.registerIconGenerationTask(project, task, buildDirectory)
}
/**
* Registers the icon generation task just for source jar generation, and not for
* compilation. This is temporarily needed since we manually parallelize compilation in
* material-icons-extended for the AGP build. When we remove that parallelization code,
* we can remove this too.
*/
@JvmStatic
@Suppress("DEPRECATION") // BaseVariant
fun registerSourceJarOnly(
project: Project,
variant: com.android.build.gradle.api.BaseVariant
) {
// Setup the source jar task if this is the release variant
if (variant.name == "release") {
val (task, buildDirectory) = project.registerGenerationTask(
"generateExtendedIcons",
ExtendedIconGenerationTask::class.java,
variant
)
val generatedSrcMainDirectory = buildDirectory.resolve(GeneratedSrcMain)
project.addToSourceJar(generatedSrcMainDirectory, task)
}
}
}
}
/**
* Helper to register [task] that outputs to [buildDirectory] as the Kotlin source generating
* task for [project].
*/
private fun registerIconGenerationTask(
project: Project,
task: TaskProvider<*>,
buildDirectory: File
) {
val sourceSet = project.getMultiplatformSourceSet(KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME)
val generatedSrcMainDirectory = buildDirectory.resolve(IconGenerationTask.GeneratedSrcMain)
sourceSet.kotlin.srcDir(project.files(generatedSrcMainDirectory).builtBy(task))
// add it to the multiplatform sources as well.
project.tasks.named("multiplatformSourceJar", Jar::class.java).configure {
it.from(task.map { generatedSrcMainDirectory })
}
project.addToSourceJar(generatedSrcMainDirectory, task)
}
/**
* Helper to register [task] as the java source generating task that outputs to [buildDirectory].
*/
@Suppress("DEPRECATION") // BaseVariant
private fun com.android.build.gradle.api.BaseVariant.registerIconGenerationTask(
project: Project,
task: TaskProvider<*>,
buildDirectory: File
) {
val generatedSrcMainDirectory = buildDirectory.resolve(IconGenerationTask.GeneratedSrcMain)
registerJavaGeneratingTask(task, generatedSrcMainDirectory)
// Setup the source jar task if this is the release variant
if (name == "release") {
project.addToSourceJar(generatedSrcMainDirectory, task)
}
}
/**
* Adds the contents of [buildDirectory] to the source jar generated for this [Project] by [task]
*/
// TODO: b/191485164 remove when AGP lets us get generated sources from a TestedExtension or
// similar, then we can just add generated sources in SourceJarTaskHelper for all projects,
// instead of needing one-off support here.
private fun Project.addToSourceJar(buildDirectory: File, task: TaskProvider<*>) {
afterEvaluate {
val sourceJar = tasks.named("sourceJarRelease", Jar::class.java)
sourceJar.configure {
// Generating source jars requires the generation task to run first. This shouldn't
// be needed for the MPP build because we use builtBy to set up the dependency
// (https://github.com/gradle/gradle/issues/17250) but the path is different for AGP,
// so we will still need this for the AGP build.
it.dependsOn(task)
it.from(buildDirectory)
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 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 androidx.compose.material.icons.generator.tasks
import androidx.compose.material.icons.generator.IconTestingManifestGenerator
import java.io.File
import org.gradle.api.Project
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.OutputDirectory
/**
* Task responsible for generating files related to testing.
*
* - Generates a list of all icons mapped to the drawable ID used in testing, so we can bitmap
* compare the programmatic icon with the original source drawable.
*
* - Flattens all the source drawables into a drawable folder that will be used in comparison tests.
*/
@CacheableTask
open class IconTestingGenerationTask : IconGenerationTask() {
/**
* Directory to generate the flattened drawables used for testing to.
*/
@get:OutputDirectory
val drawableDirectory: File
get() = generatedResourceDirectory.resolve("drawable")
override fun run() {
// Copy all drawables to the drawable directory
loadIcons().forEach { icon ->
drawableDirectory.resolve("${icon.xmlFileName}.xml").apply {
createNewFile()
writeText(icon.fileContent)
}
}
// Generate the testing manifest to the androidTest directory
IconTestingManifestGenerator(loadIcons()).generateTo(generatedSrcAndroidTestDirectory)
}
companion object {
/**
* Registers [IconTestingGenerationTask] in [project] for [variant].
*/
@Suppress("DEPRECATION") // BaseVariant
fun register(project: Project, variant: com.android.build.gradle.api.BaseVariant) {
val (task, buildDirectory) = project.registerGenerationTask(
"generateTestFiles",
IconTestingGenerationTask::class.java,
variant
)
val generatedResourceDirectory = buildDirectory.resolve(GeneratedResource)
variant.registerGeneratedResFolders(
project.files(generatedResourceDirectory).builtBy(task)
)
val generatedSrcAndroidTestDirectory = buildDirectory.resolve(GeneratedSrcAndroidTest)
variant.registerJavaGeneratingTask(task, generatedSrcAndroidTestDirectory)
}
}
}