Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
- Manifest: dom/svg/test/mochitest.toml
<!--
-->
<head>
<title>Test interpolation between different path segment types</title>
<script src="/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
</head>
<body>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=619498">Mozilla Bug 619498</a>
onload="this.pauseAnimations()"/>
<script type="application/javascript"><![CDATA[
SimpleTest.waitForExplicitFinish();
var gSVG = document.getElementById("svg");
// Array of all subtests to run. This is populated by addTest.
var gTests = [];
// Array of all path segment types.
var gTypes = "ZMmLlCcQqAaHhVvSsTt".split("");
// Property names on the SVGPathSeg objects for the given segment type, in the
// order that they would appear in a path data string.
var gArgumentNames = {
Z: [],
M: ["x", "y"],
L: ["x", "y"],
C: ["x1", "y1", "x2", "y2", "x", "y"],
Q: ["x1", "y1", "x", "y"],
A: ["r1", "r2", "angle", "largeArcFlag", "sweepFlag", "x", "y"],
H: ["x"],
V: ["y"],
S: ["x2", "y2", "x", "y"],
T: ["x", "y"],
};
// All of these prefixes leave the current point at 100,100. Some of them
// affect the implied control point if followed by a smooth quadratic or
// cubic segment, but no valid interpolations depend on those control points.
var gPrefixes = [
[1, "M100,100"],
[2, "M50,50 M100,100"],
[2, "M50,50 m50,50"],
[2, "M50,50 L100,100"],
[2, "M50,50 l50,50"],
[3, "M50,50 H100 V100"],
[3, "M50,50 h50 V100"],
[3, "M50,50 H100 v50"],
[2, "M50,50 A10,10,10,0,0,100,100"],
[2, "M50,50 a10,10,10,0,0,50,50"],
[4, "M50,50 l50,50 Z m50,50"],
// These leave the quadratic implied control point at 125,125.
[2, "M50,50 Q75,75,100,100"],
[2, "M50,50 q25,25,50,50"],
[2, "M75,75 T100,100"],
[2, "M75,75 t25,25"],
[3, "M50,50 T62.5,62.5 t37.5,37.5"],
[3, "M50,50 T62.5,62.5 T100,100"],
[3, "M50,50 t12.5,12.5 t37.5,37.5"],
[3, "M50,50 t12.5,12.5 T100,100"],
[3, "M50,50 Q50,50,62.5,62.5 t37.5,37.5"],
[3, "M50,50 Q50,50,62.5,62.5 T100,100"],
[3, "M50,50 q0,0,12.5,12.5 t37.5,37.5"],
[3, "M50,50 q0,0,12.5,12.5 T100,100"],
// These leave the cubic implied control point at 125,125.
[2, "M50,50 C10,10,75,75,100,100"],
[2, "M50,50 c10,10,25,25,50,50"],
[2, "M50,50 S75,75,100,100"],
[2, "M50,50 s25,25,50,50"],
[3, "M50,50 S10,10,75,75 S75,75,100,100"],
[3, "M50,50 S10,10,75,75 s0,0,25,25"],
[3, "M50,50 s10,10,25,25 S75,75,100,100"],
[3, "M50,50 s10,10,25,25 s0,0,25,25"],
[3, "M50,50 C10,10,20,20,75,75 S75,75,100,100"],
[3, "M50,50 C10,10,20,20,75,75 s0,0,25,25"],
[3, "M50,50 c10,10,20,20,25,25 S75,75,100,100"],
[3, "M50,50 c10,10,20,20,25,25 s0,0,25,25"],
];
// These are all of the suffixes whose result is not dependent on whether the
// preceding segment types are quadratic or cubic types. Each entry is:
//
// "<fromType><toType>": [fromArguments,
// toArguments,
// expectedArguments,
// expectedArgumentsAdditive]
//
// As an example:
//
// "Mm": [[10, 20], [30, 40], [-30, -20], [-120, -100]]
//
// This will testing interpolating between "M10,20" and "m30,40". All of the
// these tests assume that the current point is left at 100,100. So the above
// entry represents two kinds of tests, one where additive and one not:
//
// <path d="... M10,20">
// <animate attributeName="d" from="... M10,20" to="... m30,40"/>
// </path>
//
// and
//
// <path d="... M10,20">
// <animate attributeName="d" from="... M10,20" to="... m30,40"
// additive="sum"/>
// </path>
//
// where the "..." is some prefix that leaves the current point at 100,100.
// Each of the suffixes here in gSuffixes will be paired with each of the
// prefixes in gPrefixes, all of which leave the current point at 100,100.
// (Thus the above two tests for interpolating between "M" and "m" will be
// performed many times, with different preceding commands.)
//
// The expected result of the non-additive test is "m-30,-20". Since the
// animation is from an absolute moveto to a relative moveto, we first
// convert the "M10,20" into its relative form, which is "m-90,-80" due to the
// current point being 100,100. Half way through the animation between
// "m-90,-80" and "m30,40" is thus "m-30,-20".
//
// The expected result of the additive test is "m-120,-100". We take the
// halfway value of the animation, "m-30,-20" and add it on to the underlying
// value. Since the underlying value "M10,20" is an absolute moveto, we first
// convert it to relative, "m-90,-80", and then add the "m-30,-20" to it,
// giving us the result "m-120,-100".
var gSuffixes = {
// Same path segment type, no conversion required.
MM: [[10, 20], [30, 40], [20, 30], [30, 50]],
LL: [[10, 20], [30, 40], [20, 30], [30, 50]],
CC: [[10, 20, 30, 40, 50, 60], [70, 80, 90, 100, 110, 120],
[40, 50, 60, 70, 80, 90], [50, 70, 90, 110, 130, 150]],
QQ: [[10, 20, 30, 40], [50, 60, 70, 80], [30, 40, 50, 60], [40, 60, 80, 100]],
AA: [[10, 20, 30, 0, 0, 40, 50], [60, 70, 80, 0, 0, 90, 100],
[35, 45, 55, 0, 0, 65, 75], [45, 65, 85, 0, 0, 105, 125]],
HH: [[10], [20], [15], [25]],
VV: [[10], [20], [15], [25]],
SS: [[10, 20, 30, 40], [50, 60, 70, 80], [30, 40, 50, 60], [40, 60, 80, 100]],
TT: [[10, 20], [30, 40], [20, 30], [30, 50]],
// Relative <-> absolute conversion.
mM: [[10, 20], [30, 40], [70, 80], [180, 200]],
lL: [[10, 20], [30, 40], [70, 80], [180, 200]],
cC: [[10, 20, 30, 40, 50, 60], [70, 80, 90, 100, 110, 120],
[90, 100, 110, 120, 130, 140], [200, 220, 240, 260, 280, 300]],
qQ: [[10, 20, 30, 40], [50, 60, 70, 80],
[80, 90, 100, 110], [190, 210, 230, 250]],
aA: [[10, 20, 30, 0, 0, 40, 50], [60, 70, 80, 0, 0, 90, 100],
[35, 45, 55, 0, 0, 115, 125], [45, 65, 85, 0, 0, 255, 275]],
hH: [[10], [20], [65], [175]],
vV: [[10], [20], [65], [175]],
tT: [[10, 20], [30, 40], [70, 80], [180, 200]],
sS: [[10, 20, 30, 40], [50, 60, 70, 80],
[80, 90, 100, 110], [190, 210, 230, 250]],
};
// Returns an array of property names that exist on an SVGPathSeg object
// corresponding to the given segment type, in the order that they would
// be present in a path data string.
function argumentNames(aType) {
return gArgumentNames[aType.toUpperCase()];
}
// Creates and returns a new element and sets some attributes on it.
function newElement(aNamespaceURI, aLocalName, aAttributes) {
var e = document.createElementNS(aNamespaceURI, aLocalName);
if (aAttributes) {
for (let [name, value] of Object.entries(aAttributes)) {
e.setAttribute(name, value);
}
}
return e;
}
// Creates and returns a new SVG element and sets some attributes on it.
function newSVGElement(aLocalName, aAttributes) {
}
// Creates a subtest and adds it to the document.
//
// * aPrefixLength/aPrefix the prefix to use
// * aFromType/aFromArguments the segment to interpolate from
// * aToType/aToArguments the segment to interpolate to
// * aExpectedType/aExpectedArguments the expected result of the interpolated
// segment half way through the animation
// duration
// * aAdditive whether the subtest is for an additive
// animation
function addTest(aPrefixLength, aPrefix, aFromType, aFromArguments,
aToType, aToArguments, aExpectedType, aExpectedArguments,
aAdditive) {
var fromPath = aPrefix + aFromType + aFromArguments,
toPath = aPrefix + aToType + aToArguments;
var path = newSVGElement("path", { d: fromPath, id: "p" + gTests.length});
var animate =
newSVGElement("animate", { attributeName: "d",
from: fromPath,
to: toPath,
dur: "8s",
additive: aAdditive ? "sum" : "replace" });
path.appendChild(animate);
gSVG.appendChild(path);
gTests.push({ element: path,
prefixLength: aPrefixLength,
from: fromPath,
to: toPath,
toType: aToType,
expectedType: aExpectedType,
expected: aExpectedArguments,
usesAddition: aAdditive });
}
// Generates an array of path segment arguments for the given type. aOffset
// is a number to add on to all non-Boolean segment arguments.
function generatePathSegmentArguments(aType, aOffset) {
var args = new Array(argumentNames(aType).length);
for (let i = 0; i < args.length; i++) {
args[i] = i * 10 + aOffset;
}
if (aType == "A" || aType == "a") {
args[3] = 0;
args[4] = 0;
}
return args;
}
// Returns whether interpolating between the two given types is valid.
function isValidInterpolation(aFromType, aToType) {
return aFromType.toUpperCase() == aToType.toUpperCase();
}
// Adapted from SVGPathSeg polyfill by Philip Rogers (MIT License)
var commandsMap = {
"Z":"Z", "M":"M", "L":"L", "C":"C", "Q":"Q", "A":"A", "H":"H", "V":"V", "S":"S", "T":"T",
"z":"Z", "m":"m", "l":"l", "c":"c", "q":"q", "a":"a", "h":"h", "v":"v", "s":"s", "t":"t"
};
var Source = function(string) {
this._string = string;
this._currentIndex = 0;
this._endIndex = this._string.length;
this._prevCommand = null;
this._skipOptionalSpaces();
};
Source.prototype = {
parseSegment() {
var char = this._string[this._currentIndex];
var command = commandsMap[char] ? commandsMap[char] : null;
if (command === null) {
// Possibly an implicit command. Not allowed if this is the first command.
if (this._prevCommand === null) {
return null;
}
// Check for remaining coordinates in the current command.
if (
(char === "+" || char === "-" || char === "." || (char >= "0" && char <= "9")) && this._prevCommand !== "Z"
) {
if (this._prevCommand === "M") {
command = "L";
} else if (this._prevCommand === "m") {
command = "l";
} else {
command = this._prevCommand;
}
} else {
command = null;
}
if (command === null) {
return null;
}
} else {
this._currentIndex += 1;
}
this._prevCommand = command;
var values = null;
var cmd = command.toUpperCase();
if (cmd === "H" || cmd === "V") {
values = [this._parseNumber()];
} else if (cmd === "M" || cmd === "L" || cmd === "T") {
values = [this._parseNumber(), this._parseNumber()];
} else if (cmd === "S" || cmd === "Q") {
values = [this._parseNumber(), this._parseNumber(), this._parseNumber(), this._parseNumber()];
} else if (cmd === "C") {
values = [
this._parseNumber(),
this._parseNumber(),
this._parseNumber(),
this._parseNumber(),
this._parseNumber(),
this._parseNumber()
];
} else if (cmd === "A") {
values = [
this._parseNumber(),
this._parseNumber(),
this._parseNumber(),
this._parseArcFlag(),
this._parseArcFlag(),
this._parseNumber(),
this._parseNumber()
];
} else if (cmd === "Z") {
this._skipOptionalSpaces();
values = [];
}
// eslint-disable-next-line mozilla/use-includes-instead-of-indexOf
if (values === null || values.indexOf(null) >= 0) {
// Unknown command or known command with invalid values
return null;
}
return {type: command, values};
},
hasMoreData() {
return this._currentIndex < this._endIndex;
},
peekSegmentType() {
var char = this._string[this._currentIndex];
return commandsMap[char] ? commandsMap[char] : null;
},
initialCommandIsMoveTo() {
// If the path is empty it is still valid, so return true.
if (!this.hasMoreData()) {
return true;
}
var command = this.peekSegmentType();
// Path must start with moveTo.
return command === "M" || command === "m";
},
_isCurrentSpace() {
var char = this._string[this._currentIndex];
return char <= " " && (char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f");
},
_skipOptionalSpaces() {
while (this._currentIndex < this._endIndex && this._isCurrentSpace()) {
this._currentIndex += 1;
}
return this._currentIndex < this._endIndex;
},
_skipOptionalSpacesOrDelimiter() {
if (
this._currentIndex < this._endIndex &&
!this._isCurrentSpace() &&
this._string[this._currentIndex] !== ","
) {
return false;
}
if (this._skipOptionalSpaces()) {
if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ",") {
this._currentIndex += 1;
this._skipOptionalSpaces();
}
}
return this._currentIndex < this._endIndex;
},
// Parse a number from an SVG path. This very closely follows genericParseNumber(...) from
// Source/core/svg/SVGParserUtilities.cpp.
// eslint-disable-next-line complexity
_parseNumber() {
var exponent = 0;
var integer = 0;
var frac = 1;
var decimal = 0;
var sign = 1;
var expsign = 1;
var startIndex = this._currentIndex;
this._skipOptionalSpaces();
// Read the sign.
if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "+") {
this._currentIndex += 1;
} else if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "-") {
this._currentIndex += 1;
sign = -1;
}
if (
this._currentIndex === this._endIndex ||
(
(this._string[this._currentIndex] < "0" || this._string[this._currentIndex] > "9") &&
this._string[this._currentIndex] !== "."
)
) {
// The first character of a number must be one of [0-9+-.].
return null;
}
// Read the integer part, build right-to-left.
var startIntPartIndex = this._currentIndex;
while (
this._currentIndex < this._endIndex &&
this._string[this._currentIndex] >= "0" &&
this._string[this._currentIndex] <= "9"
) {
this._currentIndex += 1; // Advance to first non-digit.
}
if (this._currentIndex !== startIntPartIndex) {
var scanIntPartIndex = this._currentIndex - 1;
var multiplier = 1;
while (scanIntPartIndex >= startIntPartIndex) {
integer += multiplier * (this._string[scanIntPartIndex] - "0");
scanIntPartIndex -= 1;
multiplier *= 10;
}
}
// Read the decimals.
if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ".") {
this._currentIndex += 1;
// There must be a least one digit following the .
if (
this._currentIndex >= this._endIndex ||
this._string[this._currentIndex] < "0" ||
this._string[this._currentIndex] > "9"
) {
return null;
}
while (
this._currentIndex < this._endIndex &&
this._string[this._currentIndex] >= "0" &&
this._string[this._currentIndex] <= "9"
) {
frac *= 10;
decimal += (this._string.charAt(this._currentIndex) - "0") / frac;
this._currentIndex += 1;
}
}
// Read the exponent part.
if (
this._currentIndex !== startIndex &&
this._currentIndex + 1 < this._endIndex &&
(this._string[this._currentIndex] === "e" || this._string[this._currentIndex] === "E") &&
(this._string[this._currentIndex + 1] !== "x" && this._string[this._currentIndex + 1] !== "m")
) {
this._currentIndex += 1;
// Read the sign of the exponent.
if (this._string[this._currentIndex] === "+") {
this._currentIndex += 1;
} else if (this._string[this._currentIndex] === "-") {
this._currentIndex += 1;
expsign = -1;
}
// There must be an exponent.
if (
this._currentIndex >= this._endIndex ||
this._string[this._currentIndex] < "0" ||
this._string[this._currentIndex] > "9"
) {
return null;
}
while (
this._currentIndex < this._endIndex &&
this._string[this._currentIndex] >= "0" &&
this._string[this._currentIndex] <= "9"
) {
exponent *= 10;
exponent += (this._string[this._currentIndex] - "0");
this._currentIndex += 1;
}
}
var number = integer + decimal;
number *= sign;
if (exponent) {
number *= Math.pow(10, expsign * exponent);
}
if (startIndex === this._currentIndex) {
return null;
}
this._skipOptionalSpacesOrDelimiter();
return number;
},
_parseArcFlag() {
if (this._currentIndex >= this._endIndex) {
return null;
}
var flag = null;
var flagChar = this._string[this._currentIndex];
this._currentIndex += 1;
if (flagChar === "0") {
flag = 0;
} else if (flagChar === "1") {
flag = 1;
} else {
return null;
}
this._skipOptionalSpacesOrDelimiter();
return flag;
}
};
function parsePathDataString(string) {
if (!string || string.length === 0) return [];
var source = new Source(string);
var pathData = [];
if (source.initialCommandIsMoveTo()) {
while (source.hasMoreData()) {
var pathSeg = source.parseSegment();
if (pathSeg === null) {
break;
}
else {
pathData.push(pathSeg);
}
}
}
return pathData;
}
// Runs the test.
function run() {
for (let additive of [false, true]) {
let indexOfExpectedArguments = additive ? 3 : 2;
// Add subtests for each combination of prefix and suffix, and additive
// or not.
for (let [typePair, suffixEntry] of Object.entries(gSuffixes)) {
let fromType = typePair[0],
toType = typePair[1],
fromArguments = suffixEntry[0],
toArguments = suffixEntry[1],
expectedArguments = suffixEntry[indexOfExpectedArguments];
for (let prefixEntry of gPrefixes) {
let [prefixLength, prefix] = prefixEntry;
addTest(prefixLength, prefix, fromType, fromArguments,
toType, toArguments, toType, expectedArguments, additive);
}
}
// Test all pairs of segment types that cannot be interpolated between.
for (let fromType of gTypes) {
let fromArguments = generatePathSegmentArguments(fromType, 0);
for (let toType of gTypes) {
if (!isValidInterpolation(fromType, toType)) {
let toArguments = generatePathSegmentArguments(toType, 1000);
addTest(1, "M100,100", fromType, fromArguments,
toType, toArguments, toType, toArguments, additive);
}
}
}
}
// Move the document time to half way through the animations.
gSVG.setCurrentTime(4);
// Inspect the results of each subtest.
for (let test of gTests) {
let style = window.getComputedStyle(document.querySelector("#" + test.element.id))
let list = parsePathDataString(style.getPropertyValue("d").split('"')[1]);
is(list.length, test.prefixLength + 1,
"Length of animatedPathSegList for interpolation " +
(test.usesAddition ? "with addition " : "") +
" from " + test.from + " to " + test.to);
let seg = list.at(-1);
let actual = [];
for (let i = 0; i < test.expected.length; i++) {
actual.push(seg.values[i]);
}
is(seg.type + actual, test.expectedType + test.expected,
"Path segment for interpolation " +
(test.usesAddition ? "with addition " : "") +
" from " + test.from + " to " + test.to);
}
// Clear all the tests. We have tons of them attached to the DOM and refresh driver tick will
// go through them all by calling animation controller.
gSVG.remove();
SimpleTest.finish();
}
window.addEventListener("load", run);
]]></script>
</body>
</html>