UNPKG

scratch-svg-renderer

Version:
483 lines (447 loc) 20 kB
const DOMPurify = require('dompurify'); const inlineSvgFonts = require('./font-inliner'); const SvgElement = require('./svg-element'); const convertFonts = require('./font-converter'); const fixupSvgString = require('./fixup-svg-string'); const transformStrokeWidths = require('./transform-applier'); /** * Main quirks-mode SVG rendering code. */ 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; } /** * Load an SVG from a string and measure it. * @param {string} svgString String of SVG data to draw in quirks-mode. * @return {object} the natural size, in Scratch units, of this SVG. */ measure (svgString) { this.loadString(svgString); return this._measurements; } /** * @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; // Parse string into SVG XML. const parser = new DOMParser(); svgString = fixupSvgString(svgString); this._svgDom = parser.parseFromString(svgString, 'text/xml'); if (this._svgDom.childNodes.length < 1 || this._svgDom.documentElement.localName !== 'svg') { throw new Error('Document does not appear to be SVG.'); } this._svgTag = this._svgDom.documentElement; 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. this._transformGradients(); } transformStrokeWidths(this._svgTag, window); this._transformImages(this._svgTag); if (fromVersion2) { // Transform all text elements. this._transformText(); // Transform measurements. this._transformMeasurements(); // Fix stroke roundedness. this._setGradientStrokeRoundedness(); } else if (!this._svgTag.getAttribute('viewBox')) { // Renderer expects a view box. this._transformMeasurements(); } else if (!this._svgTag.getAttribute('width') || !this._svgTag.getAttribute('height')) { this._svgTag.setAttribute('width', this._svgTag.viewBox.baseVal.width); this._svgTag.setAttribute('height', this._svgTag.viewBox.baseVal.height); } this._measurements = { width: this._svgTag.viewBox.baseVal.width, height: this._svgTag.viewBox.baseVal.height, x: this._svgTag.viewBox.baseVal.x, y: this._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,${encodeURIComponent(svgText)}`; this.loaded = false; } /** * 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. */ _transformText () { // 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(this._svgTag); convertFonts(this._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 = this._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', `${spacing}em`); tspanNode.textContent = line ? line : ' '; textElement.appendChild(tspanNode); } } } } /** * @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 in _svgTag */ _collectElements (tagName) { const elts = []; const collectElements = domElement => { if ((domElement.localName === tagName || typeof tagName === 'undefined') && domElement.getAttribute) { elts.push(domElement); } for (let i = 0; i < domElement.childNodes.length; i++) { collectElements(domElement.childNodes[i]); } }; collectElements(this._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. */ _transformGradients () { const linearGradientElements = this._collectElements('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. */ _transformImages () { const imageElements = this._collectElements('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', `${pixelatedImages} ${elt.getAttribute('style')}`); } else { elt.setAttribute('style', pixelatedImages); } } } /** * 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. */ _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; } /** * 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. */ _setGradientStrokeRoundedness () { const elements = this._collectElements(); 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'; } } } /** * 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. */ _transformMeasurements () { // 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: this._svgTag remains untouched! const rawValue = this._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 = this._findLargestStrokeWidth(this._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 this._svgTag.setAttribute('width', width); this._svgTag.setAttribute('height', height); this._svgTag.setAttribute('viewBox', `${x} ${y} ${width} ${height}`); } /** * 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. */ toString (shouldInjectFonts) { const serializer = new XMLSerializer(); let string = serializer.serializeToString(this._svgDom); if (shouldInjectFonts) { string = inlineSvgFonts(string); } return string; } /** * 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;