scratch-svg-renderer
Version:
SVG renderer for Scratch
1,339 lines (1,244 loc) • 71.1 kB
JavaScript
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define([], factory);
else if(typeof exports === 'object')
exports["ScratchSVGRenderer"] = factory();
else
root["ScratchSVGRenderer"] = factory();
})(global, () => {
return /******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({
/***/ "./src/bitmap-adapter.js"
/*!*******************************!*\
!*** ./src/bitmap-adapter.js ***!
\*******************************/
(module, __unused_webpack_exports, __webpack_require__) {
const base64js = __webpack_require__(/*! base64-js */ "base64-js");
/**
* Adapts Scratch 2.0 bitmaps for use in scratch 3.0
*/
class BitmapAdapter {
/**
* @param {?function} makeImage HTML image constructor. Tests can provide this.
* @param {?function} makeCanvas HTML canvas constructor. Tests can provide this.
*/
constructor(makeImage, makeCanvas) {
this._makeImage = makeImage ? makeImage : () => new Image();
this._makeCanvas = makeCanvas ? makeCanvas : () => document.createElement('canvas');
}
/**
* Return a canvas with the resized version of the given image, done using nearest-neighbor interpolation
* @param {CanvasImageSource} image The image to resize
* @param {int} newWidth The desired post-resize width of the image
* @param {int} newHeight The desired post-resize height of the image
* @returns {HTMLCanvasElement} A canvas with the resized image drawn on it.
*/
resize(image, newWidth, newHeight) {
// We want to always resize using nearest-neighbor interpolation. However, canvas implementations are free to
// use linear interpolation (or other "smooth" interpolation methods) when downscaling:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1360415
// It seems we can get around this by resizing in two steps: first width, then height. This will always result
// in nearest-neighbor interpolation, even when downscaling.
const stretchWidthCanvas = this._makeCanvas();
stretchWidthCanvas.width = newWidth;
stretchWidthCanvas.height = image.height;
let context = stretchWidthCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.drawImage(image, 0, 0, stretchWidthCanvas.width, stretchWidthCanvas.height);
const stretchHeightCanvas = this._makeCanvas();
stretchHeightCanvas.width = newWidth;
stretchHeightCanvas.height = newHeight;
context = stretchHeightCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.drawImage(stretchWidthCanvas, 0, 0, stretchHeightCanvas.width, stretchHeightCanvas.height);
return stretchHeightCanvas;
}
/**
* Scratch 2.0 had resolution 1 and 2 bitmaps. All bitmaps in Scratch 3.0 are equivalent
* to resolution 2 bitmaps. Therefore, converting a resolution 1 bitmap means doubling
* it in width and height.
* @param {!string} dataURI Base 64 encoded image data of the bitmap
* @param {!function} callback Node-style callback that returns updated dataURI if conversion succeeded
*/
convertResolution1Bitmap(dataURI, callback) {
const image = this._makeImage();
image.src = dataURI;
image.onload = () => {
callback(null, this.resize(image, image.width * 2, image.height * 2).toDataURL());
};
image.onerror = () => {
callback('Image load failed');
};
}
/**
* Given width/height of an uploaded item, return width/height the image will be resized
* to in Scratch 3.0
* @param {!number} oldWidth original width
* @param {!number} oldHeight original height
* @return {object} Array of new width, new height
*/
getResizedWidthHeight(oldWidth, oldHeight) {
const STAGE_WIDTH = 480;
const STAGE_HEIGHT = 360;
const STAGE_RATIO = STAGE_WIDTH / STAGE_HEIGHT;
// If both dimensions are smaller than or equal to corresponding stage dimension,
// double both dimensions
if (oldWidth <= STAGE_WIDTH && oldHeight <= STAGE_HEIGHT) {
return {
width: oldWidth * 2,
height: oldHeight * 2
};
}
// If neither dimension is larger than 2x corresponding stage dimension,
// this is an in-between image, return it as is
if (oldWidth <= STAGE_WIDTH * 2 && oldHeight <= STAGE_HEIGHT * 2) {
return {
width: oldWidth,
height: oldHeight
};
}
const imageRatio = oldWidth / oldHeight;
// Otherwise, figure out how to resize
if (imageRatio >= STAGE_RATIO) {
// Wide Image
return {
width: STAGE_WIDTH * 2,
height: STAGE_WIDTH * 2 / imageRatio
};
}
// In this case we have either:
// - A wide image, but not with as big a ratio between width and height,
// making it so that fitting the width to double stage size would leave
// the height too big to fit in double the stage height
// - A square image that's still larger than the double at least
// one of the stage dimensions, so pick the smaller of the two dimensions (to fit)
// - A tall image
// In any of these cases, resize the image to fit the height to double the stage height
return {
width: STAGE_HEIGHT * 2 * imageRatio,
height: STAGE_HEIGHT * 2
};
}
/**
* Given bitmap data, resize as necessary.
* @param {ArrayBuffer | string} fileData Base 64 encoded image data of the bitmap
* @param {string} fileType The MIME type of this file
* @returns {Promise} Resolves to resized image data Uint8Array
*/
importBitmap(fileData, fileType) {
let dataURI = fileData;
if (fileData instanceof ArrayBuffer) {
dataURI = this.convertBinaryToDataURI(fileData, fileType);
}
return new Promise((resolve, reject) => {
const image = this._makeImage();
image.src = dataURI;
image.onload = () => {
const newSize = this.getResizedWidthHeight(image.width, image.height);
if (newSize.width === image.width && newSize.height === image.height) {
// No change
resolve(this.convertDataURIToBinary(dataURI));
} else {
const resizedDataURI = this.resize(image, newSize.width, newSize.height).toDataURL();
resolve(this.convertDataURIToBinary(resizedDataURI));
}
};
image.onerror = () => {
// TODO: reject with an Error (breaking API change!)
// eslint-disable-next-line prefer-promise-reject-errors
reject('Image load failed');
};
});
}
// TODO consolidate with scratch-vm/src/util/base64-util.js
// From https://gist.github.com/borismus/1032746
convertDataURIToBinary(dataURI) {
const BASE64_MARKER = ';base64,';
const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length;
const base64 = dataURI.substring(base64Index);
const raw = window.atob(base64);
const rawLength = raw.length;
const array = new Uint8Array(new ArrayBuffer(rawLength));
for (let i = 0; i < rawLength; i++) {
array[i] = raw.charCodeAt(i);
}
return array;
}
convertBinaryToDataURI(arrayBuffer, contentType) {
return "data:".concat(contentType, ";base64,").concat(base64js.fromByteArray(new Uint8Array(arrayBuffer)));
}
}
module.exports = BitmapAdapter;
/***/ },
/***/ "./src/fixup-svg-string.js"
/*!*********************************!*\
!*** ./src/fixup-svg-string.js ***!
\*********************************/
(module) {
/**
* Fixup svg string prior to parsing.
* @param {!string} svgString String of the svg to fix.
* @returns {!string} fixed svg that should be parseable.
*/
module.exports = function (svgString) {
// Add root svg namespace if it does not exist.
const svgAttrs = svgString.match(/<svg [^>]*>/);
if (svgAttrs && svgAttrs[0].indexOf('xmlns=') === -1) {
svgString = svgString.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
}
// There are some SVGs from Illustrator that use undeclared entities.
// Just replace those entities with fake namespace references to prevent
// DOMParser from crashing
if (svgAttrs && svgAttrs[0].indexOf('&ns_') !== -1 && svgString.indexOf('<!DOCTYPE') === -1) {
svgString = svgString.replace(svgAttrs[0], svgAttrs[0].replace(/&ns_[^;]+;/g, 'http://ns.adobe.com/Extensibility/1.0/'));
}
// Some SVGs exported from Photoshop have been found to have an invalid mime type
// Chrome and Safari won't render these SVGs, so we correct it here
if (svgString.includes('data:img/png')) {
svgString = svgString.replace(
// capture entire image tag with xlink:href=and the quote - dont capture data: bit
/(<image[^>]+?xlink:href=["'])data:img\/png/g,
// use the captured <image ..... xlink:href=" then append the right data uri mime type
($0, $1) => "".concat($1, "data:image/png"));
}
// Some SVGs from Inkscape attempt to bind a prefix to a reserved namespace name.
// This will cause SVG parsing to fail, so replace these with a dummy namespace name.
// This namespace name is only valid for "xml", and if we bind "xmlns:xml" to the dummy namespace,
// parsing will fail yet again, so exclude "xmlns:xml" declarations.
const xmlnsRegex = /(<[^>]+?xmlns:(?!xml=)[^ ]+=)"http:\/\/www.w3.org\/XML\/1998\/namespace"/g;
if (svgString.match(xmlnsRegex) !== null) {
svgString = svgString.replace(
// capture the entire attribute
xmlnsRegex,
// use the captured attribute name; replace only the URL
($0, $1) => "".concat($1, "\"http://dummy.namespace\""));
}
// Strip `svg:` prefix (sometimes added by Inkscape) from all tags. They interfere with DOMPurify (prefixed tag
// names are not recognized) and the paint editor.
// This matches opening and closing tags--the capture group captures the slash if it exists, and it is reinserted
// in the replacement text.
svgString = svgString.replace(/<(\/?)\s*svg:/g, '<$1');
// The <metadata> element is not needed for rendering and sometimes contains
// unparseable garbage from Illustrator :( Empty out the contents.
// Note: [\s\S] matches everything including newlines, which .* does not
svgString = svgString.replace(/<metadata>[\s\S]*<\/metadata>/, '<metadata></metadata>');
// Empty script tags and javascript executing
svgString = svgString.replace(/<script[\s\S]*>[\s\S]*<\/script>/, '<script></script>');
return svgString;
};
/***/ },
/***/ "./src/font-converter.js"
/*!*******************************!*\
!*** ./src/font-converter.js ***!
\*******************************/
(module) {
/**
* @fileOverview Convert 2.0 fonts to 3.0 fonts.
*/
/**
* Given an SVG, replace Scratch 2.0 fonts with new 3.0 fonts. Add defaults where there are none.
* @param {SVGElement} svgTag The SVG dom object
* @return {void}
*/
const convertFonts = function convertFonts(svgTag) {
// Collect all text elements into a list.
const textElements = [];
const collectText = domElement => {
if (domElement.localName === 'text') {
textElements.push(domElement);
}
for (let i = 0; i < domElement.childNodes.length; i++) {
collectText(domElement.childNodes[i]);
}
};
collectText(svgTag);
// If there's an old font-family, switch to the new one.
for (const textElement of textElements) {
// If there's no font-family provided, provide one.
if (!textElement.getAttribute('font-family') || textElement.getAttribute('font-family') === 'Helvetica') {
textElement.setAttribute('font-family', 'Sans Serif');
} else if (textElement.getAttribute('font-family') === 'Mystery') {
textElement.setAttribute('font-family', 'Curly');
} else if (textElement.getAttribute('font-family') === 'Gloria') {
textElement.setAttribute('font-family', 'Handwriting');
} else if (textElement.getAttribute('font-family') === 'Donegal') {
textElement.setAttribute('font-family', 'Serif');
}
}
};
module.exports = convertFonts;
/***/ },
/***/ "./src/font-inliner.js"
/*!*****************************!*\
!*** ./src/font-inliner.js ***!
\*****************************/
(module, __unused_webpack_exports, __webpack_require__) {
/**
* @fileOverview Import bitmap data into Scratch 3.0, resizing image as necessary.
*/
const getFonts = __webpack_require__(/*! scratch-render-fonts */ "scratch-render-fonts");
/**
* Given SVG data, inline the fonts. This allows them to be rendered correctly when set
* as the source of an HTMLImageElement. Here is a note from tmickel:
* // Inject fonts that are needed.
* // It would be nice if there were another way to get the SVG-in-canvas
* // to render the correct font family, but I couldn't find any other way.
* // Other things I tried:
* // Just injecting the font-family into the document: no effect.
* // External stylesheet linked to by SVG: no effect.
* // Using a <link> or <style>@import</style> to link to font-family
* // injected into the document: no effect.
* @param {string} svgString The string representation of the svg to modify
* @return {string} The svg with any needed fonts inlined
*/
const inlineSvgFonts = function inlineSvgFonts(svgString) {
const FONTS = getFonts();
// Make it clear that this function only operates on strings.
// If we don't explicitly throw this here, the function silently fails.
if (typeof svgString !== 'string') {
throw new Error('SVG to be inlined is not a string');
}
// Collect fonts that need injection.
const fontsNeeded = new Set();
const fontRegex = /font-family="([^"]*)"/g;
let matches = fontRegex.exec(svgString);
while (matches) {
fontsNeeded.add(matches[1]);
matches = fontRegex.exec(svgString);
}
if (fontsNeeded.size > 0) {
let str = '<defs><style>';
for (const font of fontsNeeded) {
if (Object.prototype.hasOwnProperty.call(FONTS, font)) {
str += "".concat(FONTS[font]);
}
}
str += '</style></defs>';
svgString = svgString.replace(/<svg[^>]*>/, "$&".concat(str));
return svgString;
}
return svgString;
};
module.exports = inlineSvgFonts;
/***/ },
/***/ "./src/index.js"
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
(module, __unused_webpack_exports, __webpack_require__) {
const SVGRenderer = __webpack_require__(/*! ./svg-renderer */ "./src/svg-renderer.js");
const BitmapAdapter = __webpack_require__(/*! ./bitmap-adapter */ "./src/bitmap-adapter.js");
const inlineSvgFonts = __webpack_require__(/*! ./font-inliner */ "./src/font-inliner.js");
const loadSvgString = __webpack_require__(/*! ./load-svg-string */ "./src/load-svg-string.js");
const sanitizeSvg = __webpack_require__(/*! ./sanitize-svg */ "./src/sanitize-svg.js");
const serializeSvgToString = __webpack_require__(/*! ./serialize-svg-to-string */ "./src/serialize-svg-to-string.js");
const SvgElement = __webpack_require__(/*! ./svg-element */ "./src/svg-element.js");
const convertFonts = __webpack_require__(/*! ./font-converter */ "./src/font-converter.js");
// /**
// * Export for NPM & Node.js
// * @type {RenderWebGL}
// */
module.exports = {
BitmapAdapter: BitmapAdapter,
convertFonts: convertFonts,
inlineSvgFonts: inlineSvgFonts,
loadSvgString: loadSvgString,
sanitizeSvg: sanitizeSvg,
serializeSvgToString: serializeSvgToString,
SvgElement: SvgElement,
SVGRenderer: SVGRenderer
};
/***/ },
/***/ "./src/load-svg-string.js"
/*!********************************!*\
!*** ./src/load-svg-string.js ***!
\********************************/
(module, __unused_webpack_exports, __webpack_require__) {
const DOMPurify = __webpack_require__(/*! isomorphic-dompurify */ "isomorphic-dompurify");
const SvgElement = __webpack_require__(/*! ./svg-element */ "./src/svg-element.js");
const convertFonts = __webpack_require__(/*! ./font-converter */ "./src/font-converter.js");
const fixupSvgString = __webpack_require__(/*! ./fixup-svg-string */ "./src/fixup-svg-string.js");
const transformStrokeWidths = __webpack_require__(/*! ./transform-applier */ "./src/transform-applier.js");
/**
* @param {SVGElement} svgTag the tag to search within
* @param {string} [tagName] svg tag to search for (or collect all elements if not given)
* @return {Array} a list of elements with the given tagname
*/
const collectElements = (svgTag, tagName) => {
const elts = [];
const collectElementsInner = domElement => {
if ((domElement.localName === tagName || typeof tagName === 'undefined') && domElement.getAttribute) {
elts.push(domElement);
}
for (let i = 0; i < domElement.childNodes.length; i++) {
collectElementsInner(domElement.childNodes[i]);
}
};
collectElementsInner(svgTag);
return elts;
};
/**
* Fix SVGs to comply with SVG spec. Scratch 2 defaults to x2 = 0 when x2 is missing, but
* SVG defaults to x2 = 1 when missing.
* @param {SVGSVGElement} svgTag the SVG tag to apply the transformation to
*/
const transformGradients = svgTag => {
const linearGradientElements = collectElements(svgTag, 'linearGradient');
// For each gradient element, supply x2 if necessary.
for (const gradientElement of linearGradientElements) {
if (!gradientElement.getAttribute('x2')) {
gradientElement.setAttribute('x2', '0');
}
}
};
/**
* Fix SVGs to match appearance in Scratch 2, which used nearest neighbor scaling for bitmaps
* within SVGs.
* @param {SVGSVGElement} svgTag the SVG tag to apply the transformation to
*/
const transformImages = svgTag => {
const imageElements = collectElements(svgTag, 'image');
// For each image element, set image rendering to pixelated
const pixelatedImages = 'image-rendering: optimizespeed; image-rendering: pixelated;';
for (const elt of imageElements) {
if (elt.getAttribute('style')) {
elt.setAttribute('style', "".concat(pixelatedImages, " ").concat(elt.getAttribute('style')));
} else {
elt.setAttribute('style', pixelatedImages);
}
}
};
/**
* Transforms an SVG's text elements for Scratch 2.0 quirks.
* These quirks include:
* 1. `x` and `y` properties are removed/ignored.
* 2. Alignment is set to `text-before-edge`.
* 3. Line-breaks are converted to explicit <tspan> elements.
* 4. Any required fonts are injected.
* @param {SVGSVGElement} svgTag the SVG tag to apply the transformation to
*/
const transformText = svgTag => {
// Collect all text elements into a list.
const textElements = [];
const collectText = domElement => {
if (domElement.localName === 'text') {
textElements.push(domElement);
}
for (let i = 0; i < domElement.childNodes.length; i++) {
collectText(domElement.childNodes[i]);
}
};
collectText(svgTag);
convertFonts(svgTag);
// For each text element, apply quirks.
for (const textElement of textElements) {
// Remove x and y attributes - they are not used in Scratch.
textElement.removeAttribute('x');
textElement.removeAttribute('y');
// Set text-before-edge alignment:
// Scratch renders all text like this.
textElement.setAttribute('alignment-baseline', 'text-before-edge');
textElement.setAttribute('xml:space', 'preserve');
// If there's no font size provided, provide one.
if (!textElement.getAttribute('font-size')) {
textElement.setAttribute('font-size', '18');
}
let text = textElement.textContent;
// Fix line breaks in text, which are not natively supported by SVG.
// Only fix if text does not have child tspans.
// @todo this will not work for font sizes with units such as em, percent
// However, text made in scratch 2 should only ever export size 22 font.
const fontSize = parseFloat(textElement.getAttribute('font-size'));
const tx = 2;
let ty = 0;
let spacing = 1.2;
// Try to match the position and spacing of Scratch 2.0's fonts.
// Different fonts seem to use different line spacing.
// Scratch 2 always uses alignment-baseline=text-before-edge
// However, most SVG readers don't support this attribute
// or don't support it alongside use of tspan, so the translations
// here are to make up for that.
if (textElement.getAttribute('font-family') === 'Handwriting') {
spacing = 2;
ty = -11 * fontSize / 22;
} else if (textElement.getAttribute('font-family') === 'Scratch') {
spacing = 0.89;
ty = -3 * fontSize / 22;
} else if (textElement.getAttribute('font-family') === 'Curly') {
spacing = 1.38;
ty = -6 * fontSize / 22;
} else if (textElement.getAttribute('font-family') === 'Marker') {
spacing = 1.45;
ty = -6 * fontSize / 22;
} else if (textElement.getAttribute('font-family') === 'Sans Serif') {
spacing = 1.13;
ty = -3 * fontSize / 22;
} else if (textElement.getAttribute('font-family') === 'Serif') {
spacing = 1.25;
ty = -4 * fontSize / 22;
}
if (textElement.transform.baseVal.numberOfItems === 0) {
const transform = svgTag.createSVGTransform();
textElement.transform.baseVal.appendItem(transform);
}
// Right multiply matrix by a translation of (tx, ty)
const mtx = textElement.transform.baseVal.getItem(0).matrix;
mtx.e += mtx.a * tx + mtx.c * ty;
mtx.f += mtx.b * tx + mtx.d * ty;
if (text && textElement.childElementCount === 0) {
textElement.textContent = '';
const lines = text.split('\n');
text = '';
for (const line of lines) {
const tspanNode = SvgElement.create('tspan');
tspanNode.setAttribute('x', '0');
tspanNode.setAttribute('style', 'white-space: pre');
tspanNode.setAttribute('dy', "".concat(spacing, "em"));
tspanNode.textContent = line ? line : ' ';
textElement.appendChild(tspanNode);
}
}
}
};
/**
* Find the largest stroke width in the svg. If a shape has no
* `stroke` property, it has a stroke-width of 0. If it has a `stroke`,
* it is by default a stroke-width of 1.
* This is used to enlarge the computed bounding box, which doesn't take
* stroke width into account.
* @param {SVGSVGElement} rootNode The root SVG node to traverse.
* @return {number} The largest stroke width in the SVG.
*/
const findLargestStrokeWidth = rootNode => {
let largestStrokeWidth = 0;
const collectStrokeWidths = domElement => {
if (domElement.getAttribute) {
if (domElement.getAttribute('stroke')) {
largestStrokeWidth = Math.max(largestStrokeWidth, 1);
}
if (domElement.getAttribute('stroke-width')) {
largestStrokeWidth = Math.max(largestStrokeWidth, Number(domElement.getAttribute('stroke-width')) || 0);
}
}
for (let i = 0; i < domElement.childNodes.length; i++) {
collectStrokeWidths(domElement.childNodes[i]);
}
};
collectStrokeWidths(rootNode);
return largestStrokeWidth;
};
/**
* Transform the measurements of the SVG.
* In Scratch 2.0, SVGs are drawn without respect to the width,
* height, and viewBox attribute on the tag. The exporter
* does output these properties - but they appear to be incorrect often.
* To address the incorrect measurements, we append the DOM to the
* document, and then use SVG's native `getBBox` to find the real
* drawn dimensions. This ensures things drawn in negative dimensions,
* outside the given viewBox, etc., are all eventually drawn to the canvas.
* I tried to do this several other ways: stripping the width/height/viewBox
* attributes and then drawing (Firefox won't draw anything),
* or inflating them and then measuring a canvas. But this seems to be
* a natural and performant way.
* @param {SVGSVGElement} svgTag the SVG tag to apply the transformation to
*/
const transformMeasurements = svgTag => {
// Append the SVG dom to the document.
// This allows us to use `getBBox` on the page,
// which returns the full bounding-box of all drawn SVG
// elements, similar to how Scratch 2.0 did measurement.
const svgSpot = document.createElement('span');
// Since we're adding user-provided SVG to document.body,
// sanitizing is required. This should not affect bounding box calculation.
// outerHTML is attribute of Element (and not HTMLElement), so use it instead of
// calling serializer or toString()
// NOTE: svgTag remains untouched!
const rawValue = svgTag.outerHTML;
const sanitizedValue = DOMPurify.sanitize(rawValue, {
// Use SVG profile (no HTML elements)
USE_PROFILES: {
svg: true
},
// Remove some tags that Scratch does not use.
FORBID_TAGS: ['a', 'audio', 'canvas', 'video'],
// Allow data URI in image tags (e.g. SVGs converted from bitmap)
ADD_DATA_URI_TAGS: ['image']
});
let bbox;
try {
// Insert sanitized value.
svgSpot.innerHTML = sanitizedValue;
document.body.appendChild(svgSpot);
// Take the bounding box. We have to get elements via svgSpot
// because we added it via innerHTML.
bbox = svgSpot.children[0].getBBox();
} finally {
// Always destroy the element, even if, for example, getBBox throws.
document.body.removeChild(svgSpot);
}
// Enlarge the bbox from the largest found stroke width
// This may have false-positives, but at least the bbox will always
// contain the full graphic including strokes.
// If the width or height is zero however, don't enlarge since
// they won't have a stroke width that needs to be enlarged.
let halfStrokeWidth;
if (bbox.width === 0 || bbox.height === 0) {
halfStrokeWidth = 0;
} else {
halfStrokeWidth = findLargestStrokeWidth(svgTag) / 2;
}
const width = bbox.width + halfStrokeWidth * 2;
const height = bbox.height + halfStrokeWidth * 2;
const x = bbox.x - halfStrokeWidth;
const y = bbox.y - halfStrokeWidth;
// Set the correct measurements on the SVG tag
svgTag.setAttribute('width', width);
svgTag.setAttribute('height', height);
svgTag.setAttribute('viewBox', "".concat(x, " ").concat(y, " ").concat(width, " ").concat(height));
};
/**
* Find all instances of a URL-referenced `stroke` in the svg. In 2.0, all gradient strokes
* have a round `stroke-linejoin` and `stroke-linecap`... for some reason.
* @param {SVGSVGElement} svgTag the SVG tag to apply the transformation to
*/
const setGradientStrokeRoundedness = svgTag => {
const elements = collectElements(svgTag);
for (const elt of elements) {
if (!elt.style) continue;
const stroke = elt.style.stroke || elt.getAttribute('stroke');
if (stroke && stroke.match(/^url\(#.*\)$/)) {
elt.style['stroke-linejoin'] = 'round';
elt.style['stroke-linecap'] = 'round';
}
}
};
/**
* In-place, convert passed SVG to something consistent that will be rendered the way we want them to be.
* @param {SVGSvgElement} svgTag root SVG node to operate upon
* @param {boolean} [fromVersion2] True if we should perform conversion from version 2 to version 3 svg.
*/
const normalizeSvg = (svgTag, fromVersion2) => {
if (fromVersion2) {
// Fix gradients. Scratch 2 exports no x2 when x2 = 0, but
// SVG default is that x2 is 1. This must be done before
// transformStrokeWidths since transformStrokeWidths affects
// gradients.
transformGradients(svgTag);
}
transformStrokeWidths(svgTag, window);
transformImages(svgTag);
if (fromVersion2) {
// Transform all text elements.
transformText(svgTag);
// Transform measurements.
transformMeasurements(svgTag);
// Fix stroke roundedness.
setGradientStrokeRoundedness(svgTag);
} else if (!svgTag.getAttribute('viewBox')) {
// Renderer expects a view box.
transformMeasurements(svgTag);
} else if (!svgTag.getAttribute('width') || !svgTag.getAttribute('height')) {
svgTag.setAttribute('width', svgTag.viewBox.baseVal.width);
svgTag.setAttribute('height', svgTag.viewBox.baseVal.height);
}
};
/**
* Load an SVG string and normalize it. All the steps before drawing/measuring.
* Currently, this will normalize stroke widths (see transform-applier.js) and render all embedded images pixelated.
* The returned SVG will be guaranteed to always have a `width`, `height` and `viewBox`.
* In addition, if the `fromVersion2` parameter is `true`, several "quirks-mode" transformations will be applied which
* mimic Scratch 2.0's SVG rendering.
* @param {!string} svgString String of SVG data to draw in quirks-mode.
* @param {boolean} [fromVersion2] True if we should perform conversion from version 2 to version 3 svg.
* @return {SVGSVGElement} The normalized SVG element.
*/
const loadSvgString = (svgString, fromVersion2) => {
// Parse string into SVG XML.
const parser = new DOMParser();
svgString = fixupSvgString(svgString);
const svgDom = parser.parseFromString(svgString, 'text/xml');
if (svgDom.childNodes.length < 1 || svgDom.documentElement.localName !== 'svg') {
throw new Error('Document does not appear to be SVG.');
}
const svgTag = svgDom.documentElement;
normalizeSvg(svgTag, fromVersion2);
return svgTag;
};
module.exports = loadSvgString;
/***/ },
/***/ "./src/sanitize-svg.js"
/*!*****************************!*\
!*** ./src/sanitize-svg.js ***!
\*****************************/
(module, __unused_webpack_exports, __webpack_require__) {
/**
* @fileOverview Sanitize the content of an SVG aggressively, to make it as safe
* as possible
*/
const fixupSvgString = __webpack_require__(/*! ./fixup-svg-string */ "./src/fixup-svg-string.js");
const {
generate,
parse,
walk
} = __webpack_require__(/*! css-tree */ "css-tree");
const DOMPurify = __webpack_require__(/*! isomorphic-dompurify */ "isomorphic-dompurify");
const sanitizeSvg = {};
DOMPurify.addHook('beforeSanitizeAttributes', currentNode => {
if (currentNode && currentNode.href && currentNode.href.baseVal) {
const href = currentNode.href.baseVal.replace(/\s/g, '');
// "data:" and "#" are valid hrefs
if (href.slice(0, 5) !== 'data:' && href.slice(0, 1) !== '#') {
if (currentNode.attributes.getNamedItem('xlink:href')) {
currentNode.attributes.removeNamedItem('xlink:href');
delete currentNode['xlink:href'];
}
if (currentNode.attributes.getNamedItem('href')) {
currentNode.attributes.removeNamedItem('href');
delete currentNode.href;
}
}
}
return currentNode;
});
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
if (data.tagName === 'style') {
const ast = parse(node.textContent);
let isModified = false;
// Remove any @import rules as it could leak HTTP requests
walk(ast, (astNode, item, list) => {
if (astNode.type === 'Atrule' && astNode.name === 'import') {
list.remove(item);
isModified = true;
}
});
if (isModified) {
node.textContent = generate(ast);
}
}
});
// Use JS implemented TextDecoder and TextEncoder if it is not provided by the
// browser.
let _TextDecoder;
let _TextEncoder;
if (typeof TextDecoder === 'undefined' || typeof TextEncoder === 'undefined') {
// Wait to require the text encoding polyfill until we know it's needed.
// eslint-disable-next-line global-require
const encoding = __webpack_require__(/*! fastestsmallesttextencoderdecoder */ "fastestsmallesttextencoderdecoder");
_TextDecoder = encoding.TextDecoder;
_TextEncoder = encoding.TextEncoder;
} else {
_TextDecoder = TextDecoder;
_TextEncoder = TextEncoder;
}
/**
* Load an SVG Uint8Array of bytes and "sanitize" it
* @param {!Uint8Array} rawData unsanitized SVG daata
* @return {Uint8Array} sanitized SVG data
*/
sanitizeSvg.sanitizeByteStream = function (rawData) {
const decoder = new _TextDecoder();
const encoder = new _TextEncoder();
const sanitizedText = sanitizeSvg.sanitizeSvgText(decoder.decode(rawData));
return encoder.encode(sanitizedText);
};
/**
* Load an SVG string and "sanitize" it. This is more aggressive than the handling in
* fixup-svg-string.js, and thus more risky; there are known examples of SVGs that
* it will clobber. We use DOMPurify's svg profile, which restricts many types of tag.
* @param {!string} rawSvgText unsanitized SVG string
* @return {string} sanitized SVG text
*/
sanitizeSvg.sanitizeSvgText = function (rawSvgText) {
let sanitizedText = DOMPurify.sanitize(rawSvgText, {
USE_PROFILES: {
svg: true
}
});
// Remove partial XML comment that is sometimes left in the HTML
const badTag = sanitizedText.indexOf(']>');
if (badTag >= 0) {
sanitizedText = sanitizedText.substring(5, sanitizedText.length);
}
// also use our custom fixup rules
sanitizedText = fixupSvgString(sanitizedText);
return sanitizedText;
};
module.exports = sanitizeSvg;
/***/ },
/***/ "./src/serialize-svg-to-string.js"
/*!****************************************!*\
!*** ./src/serialize-svg-to-string.js ***!
\****************************************/
(module, __unused_webpack_exports, __webpack_require__) {
const inlineSvgFonts = __webpack_require__(/*! ./font-inliner */ "./src/font-inliner.js");
/**
* Serialize a given SVG DOM to a string.
* @param {SVGSVGElement} svgTag The SVG element to serialize.
* @param {?boolean} shouldInjectFonts True if fonts should be included in the SVG as
* base64 data.
* @returns {string} String representing current SVG data.
*/
const serializeSvgToString = (svgTag, shouldInjectFonts) => {
const serializer = new XMLSerializer();
let string = serializer.serializeToString(svgTag);
if (shouldInjectFonts) {
string = inlineSvgFonts(string);
}
return string;
};
module.exports = serializeSvgToString;
/***/ },
/***/ "./src/svg-element.js"
/*!****************************!*\
!*** ./src/svg-element.js ***!
\****************************/
(module) {
/* Adapted from
* Paper.js - The Swiss Army Knife of Vector Graphics Scripting.
* http://paperjs.org/
*
* Copyright (c) 2011 - 2016, Juerg Lehni & Jonathan Puckey
* http://scratchdisk.com/ & http://jonathanpuckey.com/
*
* Distributed under the MIT license. See LICENSE file for details.
*
* All rights reserved.
*/
/**
* @name SvgElement
* @namespace
* @private
*/
class SvgElement {
// SVG related namespaces
static get svg() {
return 'http://www.w3.org/2000/svg';
}
static get xmlns() {
return 'http://www.w3.org/2000/xmlns';
}
static get xlink() {
return 'http://www.w3.org/1999/xlink';
}
// Mapping of attribute names to required namespaces:
static attributeNamespace() {
return {
'href': SvgElement.xlink,
'xlink': SvgElement.xmlns,
// Only the xmlns attribute needs the trailing slash. See #984
'xmlns': "".concat(SvgElement.xmlns, "/"),
// IE needs the xmlns namespace when setting 'xmlns:xlink'. See #984
'xmlns:xlink': "".concat(SvgElement.xmlns, "/")
};
}
static create(tag, attributes, formatter) {
return SvgElement.set(document.createElementNS(SvgElement.svg, tag), attributes, formatter);
}
static get(node, name) {
const namespace = SvgElement.attributeNamespace[name];
const value = namespace ? node.getAttributeNS(namespace, name) : node.getAttribute(name);
return value === 'null' ? null : value;
}
static set(node, attributes, formatter) {
for (const name in attributes) {
let value = attributes[name];
const namespace = SvgElement.attributeNamespace[name];
if (typeof value === 'number' && formatter) {
value = formatter.number(value);
}
if (namespace) {
node.setAttributeNS(namespace, name, value);
} else {
node.setAttribute(name, value);
}
}
return node;
}
}
module.exports = SvgElement;
/***/ },
/***/ "./src/svg-renderer.js"
/*!*****************************!*\
!*** ./src/svg-renderer.js ***!
\*****************************/
(module, __unused_webpack_exports, __webpack_require__) {
const loadSvgString = __webpack_require__(/*! ./load-svg-string */ "./src/load-svg-string.js");
const serializeSvgToString = __webpack_require__(/*! ./serialize-svg-to-string */ "./src/serialize-svg-to-string.js");
/**
* Main quirks-mode SVG rendering code.
* @deprecated Call into individual methods exported from this library instead.
*/
class SvgRenderer {
/**
* Create a quirks-mode SVG renderer for a particular canvas.
* @param {HTMLCanvasElement} [canvas] An optional canvas element to draw to. If this is not provided, the renderer
* will create a new canvas.
* @constructor
*/
constructor(canvas) {
/**
* The canvas that this SVG renderer will render to.
* @type {HTMLCanvasElement}
* @private
*/
this._canvas = canvas || document.createElement('canvas');
this._context = this._canvas.getContext('2d');
/**
* A measured SVG "viewbox"
* @typedef {object} SvgRenderer#SvgMeasurements
* @property {number} x - The left edge of the SVG viewbox.
* @property {number} y - The top edge of the SVG viewbox.
* @property {number} width - The width of the SVG viewbox.
* @property {number} height - The height of the SVG viewbox.
*/
/**
* The measurement box of the currently loaded SVG.
* @type {SvgRenderer#SvgMeasurements}
* @private
*/
this._measurements = {
x: 0,
y: 0,
width: 0,
height: 0
};
/**
* The `<img>` element with the contents of the currently loaded SVG.
* @type {?HTMLImageElement}
* @private
*/
this._cachedImage = null;
/**
* True if this renderer's current SVG is loaded and can be rendered to the canvas.
* @type {boolean}
*/
this.loaded = false;
}
/**
* @returns {!HTMLCanvasElement} this renderer's target canvas.
*/
get canvas() {
return this._canvas;
}
/**
* @return {Array<number>} the natural size, in Scratch units, of this SVG.
*/
get size() {
return [this._measurements.width, this._measurements.height];
}
/**
* @return {Array<number>} the offset (upper left corner) of the SVG's view box.
*/
get viewOffset() {
return [this._measurements.x, this._measurements.y];
}
/**
* Load an SVG string and normalize it. All the steps before drawing/measuring.
* @param {!string} svgString String of SVG data to draw in quirks-mode.
* @param {?boolean} fromVersion2 True if we should perform conversion from
* version 2 to version 3 svg.
*/
loadString(svgString, fromVersion2) {
// New svg string invalidates the cached image
this._cachedImage = null;
const svgTag = loadSvgString(svgString, fromVersion2);
this._svgTag = svgTag;
this._measurements = {
width: svgTag.viewBox.baseVal.width,
height: svgTag.viewBox.baseVal.height,
x: svgTag.viewBox.baseVal.x,
y: svgTag.viewBox.baseVal.y
};
}
/**
* Load an SVG string, normalize it, and prepare it for (synchronous) rendering.
* @param {!string} svgString String of SVG data to draw in quirks-mode.
* @param {?boolean} fromVersion2 True if we should perform conversion from version 2 to version 3 svg.
* @param {Function} [onFinish] - An optional callback to call when the SVG is loaded and can be rendered.
*/
loadSVG(svgString, fromVersion2, onFinish) {
this.loadString(svgString, fromVersion2);
this._createSVGImage(onFinish);
}
/**
* Creates an <img> element for the currently loaded SVG string, then calls the callback once it's loaded.
* @param {Function} [onFinish] - An optional callback to call when the <img> has loaded.
*/
_createSVGImage(onFinish) {
if (this._cachedImage === null) this._cachedImage = new Image();
const img = this._cachedImage;
img.onload = () => {
this.loaded = true;
if (onFinish) onFinish();
};
const svgText = this.toString(true /* shouldInjectFonts */);
img.src = "data:image/svg+xml;utf8,".concat(encodeURIComponent(svgText));
this.loaded = false;
}
/**
* Serialize the active SVG DOM to a string.
* @param {?boolean} shouldInjectFonts True if fonts should be included in the SVG as
* base64 data.
* @returns {string} String representing current SVG data.
* @deprecated Use the standalone `serializeSvgToString` export instead.
*/
toString(shouldInjectFonts) {
return serializeSvgToString(this._svgTag, shouldInjectFonts);
}
/**
* Synchronously draw the loaded SVG to this renderer's `canvas`.
* @param {number} [scale] - Optionally, also scale the image by this factor.
*/
draw(scale) {
if (!this.loaded) throw new Error('SVG image has not finished loading');
this._drawFromImage(scale);
}
/**
* Draw to the canvas from a loaded image element.
* @param {number} [scale] - Optionally, also scale the image by this factor.
**/
_drawFromImage(scale) {
if (this._cachedImage === null) return;
const ratio = Number.isFinite(scale) ? scale : 1;
const bbox = this._measurements;
this._canvas.width = bbox.width * ratio;
this._canvas.height = bbox.height * ratio;
// Even if the canvas at the current scale has a nonzero size, the image's dimensions are floored pre-scaling.
// e.g. if an image has a width of 0.4 and is being rendered at 3x scale, the canvas will have a width of 1, but
// the image's width will be rounded down to 0 on some browsers (Firefox) prior to being drawn at that scale.
if (this._canvas.width <= 0 || this._canvas.height <= 0 || this._cachedImage.naturalWidth <= 0 || this._cachedImage.naturalHeight <= 0) return;
this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._context.setTransform(ratio, 0, 0, ratio, 0, 0);
this._context.drawImage(this._cachedImage, 0, 0);
}
}
module.exports = SvgRenderer;
/***/ },
/***/ "./src/transform-applier.js"
/*!**********************************!*\
!*** ./src/transform-applier.js ***!
\**********************************/
(module, __unused_webpack_exports, __webpack_require__) {
const Matrix = __webpack_require__(/*! transformation-matrix */ "transformation-matrix");
const SvgElement = __webpack_require__(/*! ./svg-element */ "./src/svg-element.js");
const log = __webpack_require__(/*! ./util/log */ "./src/util/log.js");
/**
* @fileOverview Apply transforms to match stroke width appearance in 2.0 and 3.0
*/
// Adapted from paper.js's Path.applyTransform
const _parseTransform = function _parseTransform(domElement) {
let matrix = Matrix.identity();
const string = domElement.attributes && domElement.attributes.transform && domElement.attributes.transform.value;
if (!string) return matrix;
// https://www.w3.org/TR/SVG/types.html#DataTypeTransformList
// Parse SVG transform string. First we split at /)\s*/, to separate
// commands
const transforms = string.split(/\)\s*/g);
for (const transform of transforms) {
if (!transform) break;
// Command come before the '(', values after
const parts = transform.split(/\(\s*/);
const command = parts[0].trim();
const v = parts[1].split(/[\s,]+/g);
// Convert values to floats
for (let j = 0; j < v.length; j++) {
v[j] = parseFloat(v[j]);
}
switch (command) {
case 'matrix':
matrix = Matrix.compose(matrix, {
a: v[0],
b: v[1],
c: v[2],
d: v[3],
e: v[4],
f: v[5]
});
break;
case 'rotate':
matrix = Matrix.compose(matrix, Matrix.rotateDEG(v[0], v[1] || 0, v[2] || 0));
break;
case 'translate':
matrix = Matrix.compose(matrix, Matrix.translate(v[0], v[1] || 0));
break;
case 'scale':
matrix = Matrix.compose(matrix, Matrix.scale(v[0], v[1] || v[0]));
break;
case 'skewX':
matrix = Matrix.compose(matrix, Matrix.skewDEG(v[0], 0));
break;
case 'skewY':
matrix = Matrix.compose(matrix, Matrix.skewDEG(0, v[0]));
break;
default:
log.error("Couldn't parse: ".concat(command));
}
}
return matrix;
};
// Adapted from paper.js's Matrix.decompose
// Given a matrix, return the x and y scale factors of the matrix
const _getScaleFactor = function _getScaleFactor(matrix) {
const a = matrix.a;
const b = matrix.b;
const c = matrix.c;
const d = matrix.d;
const det = a * d - b * c;
if (a !== 0 || b !== 0) {
const r = Math.sqrt(a * a + b * b);
return {
x: r,
y: det / r
};
}
if (c !== 0 || d !== 0) {
const s = Math.sqrt(c * c + d * d);
return {
x: det / s,
y: s
};
}
// a = b = c = d = 0
return {
x: 0,
y: 0
};
};
// Returns null if matrix is not invertible. Otherwise returns given ellipse
// transformed by transform, an object {radiusX, radiusY, rotation}.
const _calculateTransformedEllipse = function _calculateTransformedEllipse(radiusX, radiusY, theta, transform) {
theta = -theta * Math.PI / 180;
const a = transform.a;
const b = -transform.c;
const c = -transform.b;
const d = transform.d;
// Since other parameters determine the translation of the ellipse in SVG, we do not need to worry
// about what e and f are.
const det = a * d - b * c;
// Non-invertible matrix
if (det === 0) return null;
// rotA, rotB, and rotC represent Ax^2 + Bxy + Cy^2 = 1 coefficients for a rotated ellipse formula
const sinT = Math.sin(theta);
const cosT = Math.cos(theta);
const sin2T = Math.sin(2 * theta);
const rotA = cosT * cosT / radiusX / radiusX + sinT * sinT / radiusY / radiusY;
const rotB = sin2T / radiusX / radiusX - sin2T / radiusY / radiusY;
const rotC = sinT * sinT / radiusX / radiusX + cosT * cosT / radiusY / radiusY;
// Calculate the ellipse formula of the transformed ellipse
// A, B, and C represent Ax^2 + Bxy + Cy^2 = 1 / det / det coefficients in a transformed ellipse formula
// scaled by inverse det squared (to preserve accuracy)
const A = rotA * d * d - rotB * d * c + rotC * c * c;
const B = -2 * rotA * b * d + rotB * a * d + rotB * b * c - 2 * rotC * a * c;
const C = rotA * b * b - rotB * a * b + rotC * a * a;
// Derive new radii and theta from the transformed ellipse formula
const newRadiusXOverDet = Math.sqrt(2) * Math.sqrt((A + C - Math.sqrt(A * A + B * B - 2 * A * C + C * C)) / (-B * B + 4 * A * C));
const newRadiusYOverDet = 1 / Math.sqrt(A + C - 1 / newRadiusXOverDet / newRadiusXOverDet);
let temp = (A - 1 / newRadiusXOverDet / newRadiusXOverDet) / (1 / newRadiusYOverDet / newRadiusYOverDet - 1 / newRadiusXOverDet / newRadiusXOverDet);
if (temp < 0 && Math.abs(temp) < 1e-8) temp = 0; // Fix floating point issue
temp = Math.sqrt(temp);
if (Math.abs(1 - temp) < 1e-8) temp = 1; // Fix floating point issue
// Solve for which of the two possible thetas is correct
let newTheta = Math.asin(temp);
temp = B / (1 / newRadiusXOverDet / newRadiusXOverDet - 1 / newRadiusYOverDet / newRadiusYOverDet);
const newTheta2 = -newTheta;
if (Math.abs(Math.sin(2 * newTheta2) - temp) < Math.abs(Math.sin(2 * newTheta) - temp)) {
newTheta = newTheta2;
}
return {
radiusX: newRadiusXOverDet * det,
radiusY: newRadiusYOverDet * det,
rotation: -newTheta * 180 / Math.PI
};
};
// Adapted from paper.js's PathItem.setPathData
const _transformPath = function _transformPath(pathString, transform) {
if (!transform || Matrix.toString(transform) === Matrix.toString(Matrix.identity())) return pathString;
// First split the path data into parts of command-coordinates pairs
// Commands are any of these characters: mzlhvcsqta
const parts = pathString && pathString.match(/[mlhvcsqtaz][^mlhvcsqtaz]*/ig);
let coords;
let relative = false;
let previous;
let control;
let current = {
x: 0,
y: 0
};
let start = {
x: 0,
y: 0
};
let result = '';
const getCoord = function getCoord(index, coord) {
let val = +coords[index];
if (relative) {
val += current[coord];
}
return val;
};
const getPoint = function getPoint(index) {
return {
x: getCoord(index, 'x'),
y: getCoord(index + 1, 'y')
};
};
const roundTo4Places = function roundTo4Places(num) {
return Number(num.toFixed(4));
};
// Returns the transformed point as a string
const getString = function getString(point) {
const transformed = Matrix.applyToPoint(transform, point);
return "".concat(roundTo4Places(transformed.x), " ").concat(roundTo4Places(transformed.y), " ");
};
for (let i = 0, l = parts && parts.length; i < l; i++) {
const part = parts[i];
const command = part[0];
const lower = command.toLowerCase();
// Match all coordinate values
coords = part.match(/[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?/g);
const length = coords && coords.length;
relative = command === lower;
// Fix issues with z in the middle of SVG path data, not followed by
// a m command, see paper.js#413:
if (previous === 'z' && !/[mz]/.test(lower)) {
result += "M ".concat(current.x, " ").concat(current.y, " ");
}
switch (lower) {
case 'm': // Move to
case 'l':
// Line to
{
let move = lower === 'm';
for (let j = 0; j < length; j += 2) {
result += move ? 'M ' : 'L ';
current = getPoint(j);
result += getString(current);
if (move) {
start = current;
move = false;
}
}
control = current;
break;
}
case 'h': // Horizontal line
case 'v':
// Vertical line
{
const coord = lower === 'h' ? 'x' : 'y';
current = {
x: current.x,
y: current.y
}; // Clone as we're going to modify it.
for (let j = 0; j < length; j++) {
current[coord] = getCoord(j, coord);
result += "L ".concat(getString(current));
}
control = current;
break;
}
case 'c':
// Cubic Bezier curve
for (let j = 0; j < length; j += 6)