@scratch/scratch-svg-renderer 11.0.0-UEPR-176
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/README.md +89 -0
- package/dist/node/scratch-svg-renderer.js +3 -0
- package/dist/node/scratch-svg-renderer.js.LICENSE.txt +417 -0
- package/dist/node/scratch-svg-renderer.js.map +1 -0
- package/dist/web/scratch-svg-renderer.js +3 -0
- package/dist/web/scratch-svg-renderer.js.LICENSE.txt +1 -0
- package/dist/web/scratch-svg-renderer.js.map +1 -0
- package/package.json +80 -0
- package/src/bitmap-adapter.js +156 -0
- package/src/fixup-svg-string.js +61 -0
- package/src/font-converter.js +38 -0
- package/src/font-inliner.js +50 -0
- package/src/index.js +22 -0
- package/src/load-svg-string.js +334 -0
- package/src/playground/index.html +132 -0
- package/src/sanitize-svg.js +104 -0
- package/src/serialize-svg-to-string.js +19 -0
- package/src/svg-element.js +71 -0
- package/src/svg-renderer.js +169 -0
- package/src/transform-applier.js +628 -0
- package/src/util/log.js +4 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
const Matrix = require('transformation-matrix');
|
|
2
|
+
const SvgElement = require('./svg-element');
|
|
3
|
+
const log = require('./util/log');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @fileOverview Apply transforms to match stroke width appearance in 2.0 and 3.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Adapted from paper.js's Path.applyTransform
|
|
10
|
+
const _parseTransform = function (domElement) {
|
|
11
|
+
let matrix = Matrix.identity();
|
|
12
|
+
const string = domElement.attributes && domElement.attributes.transform && domElement.attributes.transform.value;
|
|
13
|
+
if (!string) return matrix;
|
|
14
|
+
// https://www.w3.org/TR/SVG/types.html#DataTypeTransformList
|
|
15
|
+
// Parse SVG transform string. First we split at /)\s*/, to separate
|
|
16
|
+
// commands
|
|
17
|
+
const transforms = string.split(/\)\s*/g);
|
|
18
|
+
for (const transform of transforms) {
|
|
19
|
+
if (!transform) break;
|
|
20
|
+
// Command come before the '(', values after
|
|
21
|
+
const parts = transform.split(/\(\s*/);
|
|
22
|
+
const command = parts[0].trim();
|
|
23
|
+
const v = parts[1].split(/[\s,]+/g);
|
|
24
|
+
// Convert values to floats
|
|
25
|
+
for (let j = 0; j < v.length; j++) {
|
|
26
|
+
v[j] = parseFloat(v[j]);
|
|
27
|
+
}
|
|
28
|
+
switch (command) {
|
|
29
|
+
case 'matrix':
|
|
30
|
+
matrix = Matrix.compose(matrix, {a: v[0], b: v[1], c: v[2], d: v[3], e: v[4], f: v[5]});
|
|
31
|
+
break;
|
|
32
|
+
case 'rotate':
|
|
33
|
+
matrix = Matrix.compose(matrix, Matrix.rotateDEG(v[0], v[1] || 0, v[2] || 0));
|
|
34
|
+
break;
|
|
35
|
+
case 'translate':
|
|
36
|
+
matrix = Matrix.compose(matrix, Matrix.translate(v[0], v[1] || 0));
|
|
37
|
+
break;
|
|
38
|
+
case 'scale':
|
|
39
|
+
matrix = Matrix.compose(matrix, Matrix.scale(v[0], v[1] || v[0]));
|
|
40
|
+
break;
|
|
41
|
+
case 'skewX':
|
|
42
|
+
matrix = Matrix.compose(matrix, Matrix.skewDEG(v[0], 0));
|
|
43
|
+
break;
|
|
44
|
+
case 'skewY':
|
|
45
|
+
matrix = Matrix.compose(matrix, Matrix.skewDEG(0, v[0]));
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
log.error(`Couldn't parse: ${command}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return matrix;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Adapted from paper.js's Matrix.decompose
|
|
55
|
+
// Given a matrix, return the x and y scale factors of the matrix
|
|
56
|
+
const _getScaleFactor = function (matrix) {
|
|
57
|
+
const a = matrix.a;
|
|
58
|
+
const b = matrix.b;
|
|
59
|
+
const c = matrix.c;
|
|
60
|
+
const d = matrix.d;
|
|
61
|
+
const det = (a * d) - (b * c);
|
|
62
|
+
|
|
63
|
+
if (a !== 0 || b !== 0) {
|
|
64
|
+
const r = Math.sqrt((a * a) + (b * b));
|
|
65
|
+
return {x: r, y: det / r};
|
|
66
|
+
}
|
|
67
|
+
if (c !== 0 || d !== 0) {
|
|
68
|
+
const s = Math.sqrt((c * c) + (d * d));
|
|
69
|
+
return {x: det / s, y: s};
|
|
70
|
+
}
|
|
71
|
+
// a = b = c = d = 0
|
|
72
|
+
return {x: 0, y: 0};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Returns null if matrix is not invertible. Otherwise returns given ellipse
|
|
76
|
+
// transformed by transform, an object {radiusX, radiusY, rotation}.
|
|
77
|
+
const _calculateTransformedEllipse = function (radiusX, radiusY, theta, transform) {
|
|
78
|
+
theta = -theta * Math.PI / 180;
|
|
79
|
+
const a = transform.a;
|
|
80
|
+
const b = -transform.c;
|
|
81
|
+
const c = -transform.b;
|
|
82
|
+
const d = transform.d;
|
|
83
|
+
// Since other parameters determine the translation of the ellipse in SVG, we do not need to worry
|
|
84
|
+
// about what e and f are.
|
|
85
|
+
const det = (a * d) - (b * c);
|
|
86
|
+
// Non-invertible matrix
|
|
87
|
+
if (det === 0) return null;
|
|
88
|
+
|
|
89
|
+
// rotA, rotB, and rotC represent Ax^2 + Bxy + Cy^2 = 1 coefficients for a rotated ellipse formula
|
|
90
|
+
const sinT = Math.sin(theta);
|
|
91
|
+
const cosT = Math.cos(theta);
|
|
92
|
+
const sin2T = Math.sin(2 * theta);
|
|
93
|
+
const rotA = (cosT * cosT / radiusX / radiusX) + (sinT * sinT / radiusY / radiusY);
|
|
94
|
+
const rotB = (sin2T / radiusX / radiusX) - (sin2T / radiusY / radiusY);
|
|
95
|
+
const rotC = (sinT * sinT / radiusX / radiusX) + (cosT * cosT / radiusY / radiusY);
|
|
96
|
+
|
|
97
|
+
// Calculate the ellipse formula of the transformed ellipse
|
|
98
|
+
// A, B, and C represent Ax^2 + Bxy + Cy^2 = 1 / det / det coefficients in a transformed ellipse formula
|
|
99
|
+
// scaled by inverse det squared (to preserve accuracy)
|
|
100
|
+
const A = ((rotA * d * d) - (rotB * d * c) + (rotC * c * c));
|
|
101
|
+
const B = ((-2 * rotA * b * d) + (rotB * a * d) + (rotB * b * c) - (2 * rotC * a * c));
|
|
102
|
+
const C = ((rotA * b * b) - (rotB * a * b) + (rotC * a * a));
|
|
103
|
+
|
|
104
|
+
// Derive new radii and theta from the transformed ellipse formula
|
|
105
|
+
const newRadiusXOverDet = Math.sqrt(2) *
|
|
106
|
+
Math.sqrt(
|
|
107
|
+
(A + C - Math.sqrt((A * A) + (B * B) - (2 * A * C) + (C * C))) /
|
|
108
|
+
((-B * B) + (4 * A * C))
|
|
109
|
+
);
|
|
110
|
+
const newRadiusYOverDet = 1 / Math.sqrt(A + C - (1 / newRadiusXOverDet / newRadiusXOverDet));
|
|
111
|
+
let temp = (A - (1 / newRadiusXOverDet / newRadiusXOverDet)) /
|
|
112
|
+
((1 / newRadiusYOverDet / newRadiusYOverDet) - (1 / newRadiusXOverDet / newRadiusXOverDet));
|
|
113
|
+
if (temp < 0 && Math.abs(temp) < 1e-8) temp = 0; // Fix floating point issue
|
|
114
|
+
temp = Math.sqrt(temp);
|
|
115
|
+
if (Math.abs(1 - temp) < 1e-8) temp = 1; // Fix floating point issue
|
|
116
|
+
// Solve for which of the two possible thetas is correct
|
|
117
|
+
let newTheta = Math.asin(temp);
|
|
118
|
+
temp = (B / (
|
|
119
|
+
(1 / newRadiusXOverDet / newRadiusXOverDet) -
|
|
120
|
+
(1 / newRadiusYOverDet / newRadiusYOverDet)));
|
|
121
|
+
const newTheta2 = -newTheta;
|
|
122
|
+
if (Math.abs(Math.sin(2 * newTheta2) - temp) <
|
|
123
|
+
Math.abs(Math.sin(2 * newTheta) - temp)) {
|
|
124
|
+
newTheta = newTheta2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
radiusX: newRadiusXOverDet * det,
|
|
129
|
+
radiusY: newRadiusYOverDet * det,
|
|
130
|
+
rotation: -newTheta * 180 / Math.PI
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Adapted from paper.js's PathItem.setPathData
|
|
135
|
+
const _transformPath = function (pathString, transform) {
|
|
136
|
+
if (!transform || Matrix.toString(transform) === Matrix.toString(Matrix.identity())) return pathString;
|
|
137
|
+
// First split the path data into parts of command-coordinates pairs
|
|
138
|
+
// Commands are any of these characters: mzlhvcsqta
|
|
139
|
+
const parts = pathString && pathString.match(/[mlhvcsqtaz][^mlhvcsqtaz]*/ig);
|
|
140
|
+
let coords;
|
|
141
|
+
let relative = false;
|
|
142
|
+
let previous;
|
|
143
|
+
let control;
|
|
144
|
+
let current = {x: 0, y: 0};
|
|
145
|
+
let start = {x: 0, y: 0};
|
|
146
|
+
let result = '';
|
|
147
|
+
|
|
148
|
+
const getCoord = function (index, coord) {
|
|
149
|
+
let val = +coords[index];
|
|
150
|
+
if (relative) {
|
|
151
|
+
val += current[coord];
|
|
152
|
+
}
|
|
153
|
+
return val;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const getPoint = function (index) {
|
|
157
|
+
return {x: getCoord(index, 'x'), y: getCoord(index + 1, 'y')};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const roundTo4Places = function (num) {
|
|
161
|
+
return Number(num.toFixed(4));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Returns the transformed point as a string
|
|
165
|
+
const getString = function (point) {
|
|
166
|
+
const transformed = Matrix.applyToPoint(transform, point);
|
|
167
|
+
return `${roundTo4Places(transformed.x)} ${roundTo4Places(transformed.y)} `;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (let i = 0, l = parts && parts.length; i < l; i++) {
|
|
171
|
+
const part = parts[i];
|
|
172
|
+
const command = part[0];
|
|
173
|
+
const lower = command.toLowerCase();
|
|
174
|
+
// Match all coordinate values
|
|
175
|
+
coords = part.match(/[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g);
|
|
176
|
+
const length = coords && coords.length;
|
|
177
|
+
relative = command === lower;
|
|
178
|
+
// Fix issues with z in the middle of SVG path data, not followed by
|
|
179
|
+
// a m command, see paper.js#413:
|
|
180
|
+
if (previous === 'z' && !/[mz]/.test(lower)) {
|
|
181
|
+
result += `M ${current.x} ${current.y} `;
|
|
182
|
+
}
|
|
183
|
+
switch (lower) {
|
|
184
|
+
case 'm': // Move to
|
|
185
|
+
case 'l': // Line to
|
|
186
|
+
{
|
|
187
|
+
let move = lower === 'm';
|
|
188
|
+
for (let j = 0; j < length; j += 2) {
|
|
189
|
+
result += move ? 'M ' : 'L ';
|
|
190
|
+
current = getPoint(j);
|
|
191
|
+
result += getString(current);
|
|
192
|
+
if (move) {
|
|
193
|
+
start = current;
|
|
194
|
+
move = false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
control = current;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'h': // Horizontal line
|
|
201
|
+
case 'v': // Vertical line
|
|
202
|
+
{
|
|
203
|
+
const coord = lower === 'h' ? 'x' : 'y';
|
|
204
|
+
current = {x: current.x, y: current.y}; // Clone as we're going to modify it.
|
|
205
|
+
for (let j = 0; j < length; j++) {
|
|
206
|
+
current[coord] = getCoord(j, coord);
|
|
207
|
+
result += `L ${getString(current)}`;
|
|
208
|
+
}
|
|
209
|
+
control = current;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'c':
|
|
213
|
+
// Cubic Bezier curve
|
|
214
|
+
for (let j = 0; j < length; j += 6) {
|
|
215
|
+
const handle1 = getPoint(j);
|
|
216
|
+
control = getPoint(j + 2);
|
|
217
|
+
current = getPoint(j + 4);
|
|
218
|
+
result += `C ${getString(handle1)}${getString(control)}${getString(current)}`;
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
case 's':
|
|
222
|
+
// Smooth cubic Bezier curve
|
|
223
|
+
for (let j = 0; j < length; j += 4) {
|
|
224
|
+
const handle1 = /[cs]/.test(previous) ?
|
|
225
|
+
{x: (current.x * 2) - control.x, y: (current.y * 2) - control.y} :
|
|
226
|
+
current;
|
|
227
|
+
control = getPoint(j);
|
|
228
|
+
current = getPoint(j + 2);
|
|
229
|
+
|
|
230
|
+
result += `C ${getString(handle1)}${getString(control)}${getString(current)}`;
|
|
231
|
+
previous = lower;
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
case 'q':
|
|
235
|
+
// Quadratic Bezier curve
|
|
236
|
+
for (let j = 0; j < length; j += 4) {
|
|
237
|
+
control = getPoint(j);
|
|
238
|
+
current = getPoint(j + 2);
|
|
239
|
+
result += `Q ${getString(control)}${getString(current)}`;
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
case 't':
|
|
243
|
+
// Smooth quadratic Bezier curve
|
|
244
|
+
for (let j = 0; j < length; j += 2) {
|
|
245
|
+
control = /[qt]/.test(previous) ?
|
|
246
|
+
{x: (current.x * 2) - control.x, y: (current.y * 2) - control.y} :
|
|
247
|
+
current;
|
|
248
|
+
current = getPoint(j);
|
|
249
|
+
|
|
250
|
+
result += `Q ${getString(control)}${getString(current)}`;
|
|
251
|
+
previous = lower;
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
case 'a':
|
|
255
|
+
// Elliptical arc curve
|
|
256
|
+
for (let j = 0; j < length; j += 7) {
|
|
257
|
+
current = getPoint(j + 5);
|
|
258
|
+
const rx = +coords[j];
|
|
259
|
+
const ry = +coords[j + 1];
|
|
260
|
+
const rotation = +coords[j + 2];
|
|
261
|
+
const largeArcFlag = +coords[j + 3];
|
|
262
|
+
let clockwiseFlag = +coords[j + 4];
|
|
263
|
+
const newEllipse = _calculateTransformedEllipse(rx, ry, rotation, transform);
|
|
264
|
+
const matrixScale = _getScaleFactor(transform);
|
|
265
|
+
if (newEllipse) {
|
|
266
|
+
if ((matrixScale.x > 0 && matrixScale.y < 0) ||
|
|
267
|
+
(matrixScale.x < 0 && matrixScale.y > 0)) {
|
|
268
|
+
clockwiseFlag = clockwiseFlag ^ 1;
|
|
269
|
+
}
|
|
270
|
+
result += `A ${roundTo4Places(Math.abs(newEllipse.radiusX))} ` +
|
|
271
|
+
`${roundTo4Places(Math.abs(newEllipse.radiusY))} ` +
|
|
272
|
+
`${roundTo4Places(newEllipse.rotation)} ${largeArcFlag} ` +
|
|
273
|
+
`${clockwiseFlag} ${getString(current)}`;
|
|
274
|
+
} else {
|
|
275
|
+
result += `L ${getString(current)}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
case 'z':
|
|
280
|
+
// Close path
|
|
281
|
+
result += `Z `;
|
|
282
|
+
// Correctly handle relative m commands, see paper.js#1101:
|
|
283
|
+
current = start;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
previous = lower;
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const GRAPHICS_ELEMENTS = ['circle', 'ellipse', 'image', 'line', 'path', 'polygon', 'polyline', 'rect', 'text', 'use'];
|
|
292
|
+
const CONTAINER_ELEMENTS = ['a', 'defs', 'g', 'marker', 'glyph', 'missing-glyph', 'pattern', 'svg', 'switch', 'symbol'];
|
|
293
|
+
const _isContainerElement = function (element) {
|
|
294
|
+
return element.tagName && CONTAINER_ELEMENTS.includes(element.tagName.toLowerCase());
|
|
295
|
+
};
|
|
296
|
+
const _isGraphicsElement = function (element) {
|
|
297
|
+
return element.tagName && GRAPHICS_ELEMENTS.includes(element.tagName.toLowerCase());
|
|
298
|
+
};
|
|
299
|
+
const _isPathWithTransformAndStroke = function (element, strokeWidth) {
|
|
300
|
+
if (!element.attributes) return false;
|
|
301
|
+
strokeWidth = element.attributes['stroke-width'] ?
|
|
302
|
+
Number(element.attributes['stroke-width'].value) : Number(strokeWidth);
|
|
303
|
+
return strokeWidth &&
|
|
304
|
+
element.tagName && element.tagName.toLowerCase() === 'path' &&
|
|
305
|
+
element.attributes.d && element.attributes.d.value;
|
|
306
|
+
};
|
|
307
|
+
const _quadraticMean = function (a, b) {
|
|
308
|
+
return Math.sqrt(((a * a) + (b * b)) / 2);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const _createGradient = function (gradientId, svgTag, bbox, matrix) {
|
|
312
|
+
// Adapted from Paper.js's SvgImport.getValue
|
|
313
|
+
const getValue = function (node, name, isString, allowNull, allowPercent, defaultValue) {
|
|
314
|
+
// Interpret value as number. Never return NaN, but 0 instead.
|
|
315
|
+
// If the value is a sequence of numbers, parseFloat will
|
|
316
|
+
// return the first occurring number, which is enough for now.
|
|
317
|
+
let value = SvgElement.get(node, name);
|
|
318
|
+
let res;
|
|
319
|
+
if (value === null) {
|
|
320
|
+
if (defaultValue) {
|
|
321
|
+
res = defaultValue;
|
|
322
|
+
if (/%\s*$/.test(res)) {
|
|
323
|
+
value = defaultValue;
|
|
324
|
+
res = parseFloat(value);
|
|
325
|
+
}
|
|
326
|
+
} else if (allowNull) {
|
|
327
|
+
res = null;
|
|
328
|
+
} else if (isString) {
|
|
329
|
+
res = '';
|
|
330
|
+
} else {
|
|
331
|
+
res = 0;
|
|
332
|
+
}
|
|
333
|
+
} else if (isString) {
|
|
334
|
+
res = value;
|
|
335
|
+
} else {
|
|
336
|
+
res = parseFloat(value);
|
|
337
|
+
}
|
|
338
|
+
// Support for dimensions in percentage of the root size. If root-size
|
|
339
|
+
// is not set (e.g. during <defs>), just scale the percentage value to
|
|
340
|
+
// 0..1, as required by gradients with gradientUnits="objectBoundingBox"
|
|
341
|
+
if (/%\s*$/.test(value)) {
|
|
342
|
+
const size = allowPercent ? 1 : bbox[/x|^width/.test(name) ? 'width' : 'height'];
|
|
343
|
+
return res / 100 * size;
|
|
344
|
+
}
|
|
345
|
+
return res;
|
|
346
|
+
};
|
|
347
|
+
const getPoint = function (node, x, y, allowNull, allowPercent, defaultX, defaultY) {
|
|
348
|
+
x = getValue(node, x || 'x', false, allowNull, allowPercent, defaultX);
|
|
349
|
+
y = getValue(node, y || 'y', false, allowNull, allowPercent, defaultY);
|
|
350
|
+
return allowNull && (x === null || y === null) ? null : {x, y};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
let defs = svgTag.getElementsByTagName('defs');
|
|
354
|
+
if (defs.length === 0) {
|
|
355
|
+
defs = SvgElement.create('defs');
|
|
356
|
+
svgTag.appendChild(defs);
|
|
357
|
+
} else {
|
|
358
|
+
defs = defs[0];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Clone the old gradient. We'll make a new one, since the gradient might be reused elsewhere
|
|
362
|
+
// with different transform matrix
|
|
363
|
+
const oldGradient = svgTag.getElementById(gradientId);
|
|
364
|
+
if (!oldGradient) return;
|
|
365
|
+
|
|
366
|
+
const radial = oldGradient.tagName.toLowerCase() === 'radialgradient';
|
|
367
|
+
const newGradient = svgTag.getElementById(gradientId).cloneNode(true /* deep */);
|
|
368
|
+
|
|
369
|
+
// Give the new gradient a new ID
|
|
370
|
+
let matrixString = Matrix.toString(matrix);
|
|
371
|
+
matrixString = matrixString.substring(8, matrixString.length - 1);
|
|
372
|
+
const newGradientId = `${gradientId}-${matrixString}`;
|
|
373
|
+
newGradient.setAttribute('id', newGradientId);
|
|
374
|
+
|
|
375
|
+
// This gradient already exists and was transformed before. Just reuse the already-transformed one.
|
|
376
|
+
if (svgTag.getElementById(newGradientId)) {
|
|
377
|
+
// This is the same code as in the end of the function, but I don't feel like wrapping the next 80 lines
|
|
378
|
+
// in an `if (!svgTag.getElementById(newGradientId))` block
|
|
379
|
+
return `url(#${newGradientId})`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const scaleToBounds = getValue(newGradient, 'gradientUnits', true) !==
|
|
383
|
+
'userSpaceOnUse';
|
|
384
|
+
let origin;
|
|
385
|
+
let destination;
|
|
386
|
+
let radius;
|
|
387
|
+
let focal;
|
|
388
|
+
if (radial) {
|
|
389
|
+
origin = getPoint(newGradient, 'cx', 'cy', false, scaleToBounds, '50%', '50%');
|
|
390
|
+
radius = getValue(newGradient, 'r', false, false, scaleToBounds, '50%');
|
|
391
|
+
focal = getPoint(newGradient, 'fx', 'fy', true, scaleToBounds);
|
|
392
|
+
} else {
|
|
393
|
+
origin = getPoint(newGradient, 'x1', 'y1', false, scaleToBounds);
|
|
394
|
+
destination = getPoint(newGradient, 'x2', 'y2', false, scaleToBounds, '1');
|
|
395
|
+
if (origin.x === destination.x && origin.y === destination.y) {
|
|
396
|
+
// If it's degenerate, use the color of the last stop, as described by
|
|
397
|
+
// https://www.w3.org/TR/SVG/pservers.html#LinearGradientNotes
|
|
398
|
+
const stops = newGradient.getElementsByTagName('stop');
|
|
399
|
+
if (!stops.length || !stops[stops.length - 1].attributes ||
|
|
400
|
+
!stops[stops.length - 1].attributes['stop-color']) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return stops[stops.length - 1].attributes['stop-color'].value;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Transform points
|
|
408
|
+
// Emulate SVG's gradientUnits="objectBoundingBox"
|
|
409
|
+
if (scaleToBounds) {
|
|
410
|
+
const boundsMatrix = Matrix.compose(Matrix.translate(bbox.x, bbox.y), Matrix.scale(bbox.width, bbox.height));
|
|
411
|
+
origin = Matrix.applyToPoint(boundsMatrix, origin);
|
|
412
|
+
if (destination) destination = Matrix.applyToPoint(boundsMatrix, destination);
|
|
413
|
+
if (radius) {
|
|
414
|
+
radius = _quadraticMean(bbox.width, bbox.height) * radius;
|
|
415
|
+
}
|
|
416
|
+
if (focal) focal = Matrix.applyToPoint(boundsMatrix, focal);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (radial) {
|
|
420
|
+
origin = Matrix.applyToPoint(matrix, origin);
|
|
421
|
+
const matrixScale = _getScaleFactor(matrix);
|
|
422
|
+
radius = _quadraticMean(matrixScale.x, matrixScale.y) * radius;
|
|
423
|
+
if (focal) focal = Matrix.applyToPoint(matrix, focal);
|
|
424
|
+
} else {
|
|
425
|
+
const dot = (a, b) => (a.x * b.x) + (a.y * b.y);
|
|
426
|
+
const multiply = (coefficient, v) => ({x: coefficient * v.x, y: coefficient * v.y});
|
|
427
|
+
const add = (a, b) => ({x: a.x + b.x, y: a.y + b.y});
|
|
428
|
+
const subtract = (a, b) => ({x: a.x - b.x, y: a.y - b.y});
|
|
429
|
+
|
|
430
|
+
// The line through origin and gradientPerpendicular is the line at which the gradient starts
|
|
431
|
+
let gradientPerpendicular = Math.abs(origin.x - destination.x) < 1e-8 ?
|
|
432
|
+
add(origin, {x: 1, y: (origin.x - destination.x) / (destination.y - origin.y)}) :
|
|
433
|
+
add(origin, {x: (destination.y - origin.y) / (origin.x - destination.x), y: 1});
|
|
434
|
+
|
|
435
|
+
// Transform points
|
|
436
|
+
gradientPerpendicular = Matrix.applyToPoint(matrix, gradientPerpendicular);
|
|
437
|
+
origin = Matrix.applyToPoint(matrix, origin);
|
|
438
|
+
destination = Matrix.applyToPoint(matrix, destination);
|
|
439
|
+
|
|
440
|
+
// Calculate the direction that the gradient has changed to
|
|
441
|
+
const originToPerpendicular = subtract(gradientPerpendicular, origin);
|
|
442
|
+
const originToDestination = subtract(destination, origin);
|
|
443
|
+
const gradientDirection = Math.abs(originToPerpendicular.x) < 1e-8 ?
|
|
444
|
+
{x: 1, y: -originToPerpendicular.x / originToPerpendicular.y} :
|
|
445
|
+
{x: -originToPerpendicular.y / originToPerpendicular.x, y: 1};
|
|
446
|
+
|
|
447
|
+
// Set the destination so that the gradient moves in the correct direction, by projecting the destination vector
|
|
448
|
+
// onto the gradient direction vector
|
|
449
|
+
const projectionCoeff = dot(originToDestination, gradientDirection) / dot(gradientDirection, gradientDirection);
|
|
450
|
+
const projection = multiply(projectionCoeff, gradientDirection);
|
|
451
|
+
destination = {x: origin.x + projection.x, y: origin.y + projection.y};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Put values back into svg
|
|
455
|
+
if (radial) {
|
|
456
|
+
newGradient.setAttribute('cx', Number(origin.x.toFixed(4)));
|
|
457
|
+
newGradient.setAttribute('cy', Number(origin.y.toFixed(4)));
|
|
458
|
+
newGradient.setAttribute('r', Number(radius.toFixed(4)));
|
|
459
|
+
if (focal) {
|
|
460
|
+
newGradient.setAttribute('fx', Number(focal.x.toFixed(4)));
|
|
461
|
+
newGradient.setAttribute('fy', Number(focal.y.toFixed(4)));
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
newGradient.setAttribute('x1', Number(origin.x.toFixed(4)));
|
|
465
|
+
newGradient.setAttribute('y1', Number(origin.y.toFixed(4)));
|
|
466
|
+
newGradient.setAttribute('x2', Number(destination.x.toFixed(4)));
|
|
467
|
+
newGradient.setAttribute('y2', Number(destination.y.toFixed(4)));
|
|
468
|
+
}
|
|
469
|
+
newGradient.setAttribute('gradientUnits', 'userSpaceOnUse');
|
|
470
|
+
defs.appendChild(newGradient);
|
|
471
|
+
|
|
472
|
+
return `url(#${newGradientId})`;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// Adapted from paper.js's SvgImport.getDefinition
|
|
476
|
+
const _parseUrl = (value, windowRef) => {
|
|
477
|
+
// When url() comes from a style property, '#'' seems to be missing on
|
|
478
|
+
// WebKit. We also get variations of quotes or no quotes, single or
|
|
479
|
+
// double, so handle it all with one regular expression:
|
|
480
|
+
const match = value && value.match(/\((?:["'#]*)([^"')]+)/);
|
|
481
|
+
const name = match && match[1];
|
|
482
|
+
const res = name && windowRef ?
|
|
483
|
+
// This is required by Firefox, which can produce absolute
|
|
484
|
+
// urls for local gradients, see paperjs#1001:
|
|
485
|
+
name.replace(`${windowRef.location.href.split('#')[0]}#`, '') :
|
|
486
|
+
name;
|
|
487
|
+
return res;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Scratch 2.0 displays stroke widths in a "normalized" way, that is,
|
|
492
|
+
* if a shape with a stroke width has a transform applied, it will be
|
|
493
|
+
* rendered with a stroke that is the same width all the way around,
|
|
494
|
+
* instead of stretched looking.
|
|
495
|
+
*
|
|
496
|
+
* The vector paint editor also prefers to normalize the stroke width,
|
|
497
|
+
* rather than keep track of transforms at the group level, as this
|
|
498
|
+
* simplifies editing (e.g. stroke width 3 always means the same thickness)
|
|
499
|
+
*
|
|
500
|
+
* This function performs that normalization process, pushing transforms
|
|
501
|
+
* on groups down to the leaf level and averaging out the stroke width
|
|
502
|
+
* around the shapes. Note that this doens't just change stroke widths, it
|
|
503
|
+
* changes path data and attributes throughout the SVG.
|
|
504
|
+
*
|
|
505
|
+
* @param {SVGElement} svgTag The SVG dom object
|
|
506
|
+
* @param {Window} windowRef The window to use. Need to pass in for
|
|
507
|
+
* tests to work, as they get angry at even the mention of window.
|
|
508
|
+
* @param {object} bboxForTesting The bounds to use. Need to pass in for
|
|
509
|
+
* tests only, because getBBox doesn't work in Node. This should
|
|
510
|
+
* be the bounds of the svgTag without including stroke width or transforms.
|
|
511
|
+
* @return {void}
|
|
512
|
+
*/
|
|
513
|
+
const transformStrokeWidths = function (svgTag, windowRef, bboxForTesting) {
|
|
514
|
+
const inherited = Matrix.identity();
|
|
515
|
+
|
|
516
|
+
const applyTransforms = (element, matrix, strokeWidth, fill, stroke) => {
|
|
517
|
+
if (_isContainerElement(element)) {
|
|
518
|
+
// Push fills and stroke width down to leaves
|
|
519
|
+
if (element.attributes['stroke-width']) {
|
|
520
|
+
strokeWidth = element.attributes['stroke-width'].value;
|
|
521
|
+
}
|
|
522
|
+
if (element.attributes) {
|
|
523
|
+
if (element.attributes.fill) fill = element.attributes.fill.value;
|
|
524
|
+
if (element.attributes.stroke) stroke = element.attributes.stroke.value;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// If any child nodes don't take attributes, leave the attributes
|
|
528
|
+
// at the parent level.
|
|
529
|
+
for (let i = 0; i < element.childNodes.length; i++) {
|
|
530
|
+
applyTransforms(
|
|
531
|
+
element.childNodes[i],
|
|
532
|
+
Matrix.compose(matrix, _parseTransform(element)),
|
|
533
|
+
strokeWidth,
|
|
534
|
+
fill,
|
|
535
|
+
stroke
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
element.removeAttribute('transform');
|
|
539
|
+
element.removeAttribute('stroke-width');
|
|
540
|
+
element.removeAttribute('fill');
|
|
541
|
+
element.removeAttribute('stroke');
|
|
542
|
+
} else if (_isPathWithTransformAndStroke(element, strokeWidth)) {
|
|
543
|
+
if (element.attributes['stroke-width']) {
|
|
544
|
+
strokeWidth = element.attributes['stroke-width'].value;
|
|
545
|
+
}
|
|
546
|
+
if (element.attributes.fill) fill = element.attributes.fill.value;
|
|
547
|
+
if (element.attributes.stroke) stroke = element.attributes.stroke.value;
|
|
548
|
+
matrix = Matrix.compose(matrix, _parseTransform(element));
|
|
549
|
+
if (Matrix.toString(matrix) === Matrix.toString(Matrix.identity())) {
|
|
550
|
+
element.removeAttribute('transform');
|
|
551
|
+
element.setAttribute('stroke-width', strokeWidth);
|
|
552
|
+
if (fill) element.setAttribute('fill', fill);
|
|
553
|
+
if (stroke) element.setAttribute('stroke', stroke);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Transform gradient
|
|
558
|
+
const fillGradientId = _parseUrl(fill, windowRef);
|
|
559
|
+
const strokeGradientId = _parseUrl(stroke, windowRef);
|
|
560
|
+
|
|
561
|
+
if (fillGradientId || strokeGradientId) {
|
|
562
|
+
const doc = windowRef.document;
|
|
563
|
+
// Need path bounds to transform gradient
|
|
564
|
+
const svgSpot = doc.createElement('span');
|
|
565
|
+
let bbox;
|
|
566
|
+
if (bboxForTesting) {
|
|
567
|
+
bbox = bboxForTesting;
|
|
568
|
+
} else {
|
|
569
|
+
try {
|
|
570
|
+
doc.body.appendChild(svgSpot);
|
|
571
|
+
const svg = SvgElement.set(doc.createElementNS(SvgElement.svg, 'svg'));
|
|
572
|
+
const path = SvgElement.set(doc.createElementNS(SvgElement.svg, 'path'));
|
|
573
|
+
path.setAttribute('d', element.attributes.d.value);
|
|
574
|
+
svg.appendChild(path);
|
|
575
|
+
svgSpot.appendChild(svg);
|
|
576
|
+
// Take the bounding box.
|
|
577
|
+
bbox = svg.getBBox();
|
|
578
|
+
} finally {
|
|
579
|
+
// Always destroy the element, even if, for example, getBBox throws.
|
|
580
|
+
doc.body.removeChild(svgSpot);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (fillGradientId) {
|
|
585
|
+
const newFillRef = _createGradient(fillGradientId, svgTag, bbox, matrix);
|
|
586
|
+
if (newFillRef) fill = newFillRef;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (strokeGradientId) {
|
|
590
|
+
const newStrokeRef = _createGradient(strokeGradientId, svgTag, bbox, matrix);
|
|
591
|
+
if (newStrokeRef) stroke = newStrokeRef;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Transform path data
|
|
596
|
+
element.setAttribute('d', _transformPath(element.attributes.d.value, matrix));
|
|
597
|
+
element.removeAttribute('transform');
|
|
598
|
+
|
|
599
|
+
// Transform stroke width
|
|
600
|
+
const matrixScale = _getScaleFactor(matrix);
|
|
601
|
+
element.setAttribute('stroke-width', _quadraticMean(matrixScale.x, matrixScale.y) * strokeWidth);
|
|
602
|
+
if (fill) element.setAttribute('fill', fill);
|
|
603
|
+
if (stroke) element.setAttribute('stroke', stroke);
|
|
604
|
+
} else if (_isGraphicsElement(element)) {
|
|
605
|
+
// Push stroke width, fill, and stroke down to leaves
|
|
606
|
+
if (strokeWidth && !element.attributes['stroke-width']) {
|
|
607
|
+
element.setAttribute('stroke-width', strokeWidth);
|
|
608
|
+
}
|
|
609
|
+
if (fill && !element.attributes.fill) {
|
|
610
|
+
element.setAttribute('fill', fill);
|
|
611
|
+
}
|
|
612
|
+
if (stroke && !element.attributes.stroke) {
|
|
613
|
+
element.setAttribute('stroke', stroke);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Push transform down to leaves
|
|
617
|
+
matrix = Matrix.compose(matrix, _parseTransform(element));
|
|
618
|
+
if (Matrix.toString(matrix) === Matrix.toString(Matrix.identity())) {
|
|
619
|
+
element.removeAttribute('transform');
|
|
620
|
+
} else {
|
|
621
|
+
element.setAttribute('transform', Matrix.toString(matrix));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
applyTransforms(svgTag, inherited, 1 /* default SVG stroke width */);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
module.exports = transformStrokeWidths;
|
package/src/util/log.js
ADDED