Extract svg-renderer out into a separate module

This commit is contained in:
Christopher Willis-Ford
2018-01-09 14:52:12 -08:00
parent 38057dd884
commit 413c1d8027
4 changed files with 6 additions and 342 deletions

View File

@@ -41,7 +41,7 @@
"json": "^9.0.4",
"linebreak": "0.3.0",
"raw-loader": "^0.5.1",
"scratch-render-fonts": "git+https://github.com/LLK/scratch-render-fonts.git",
"scratch-svg-renderer": "git+https://github.com/LLK/scratch-svg-renderer.git",
"tap": "^10.3.0",
"travis-after-all": "^1.4.4",
"twgl.js": "3.7.0",

View File

@@ -1,9 +1,9 @@
const twgl = require('twgl.js');
const Skin = require('./Skin');
const SvgRenderer = require('./svg-quirks-mode/svg-renderer');
const Silhouette = require('./Silhouette');
const Skin = require('./Skin');
import SvgRenderer from 'scratch-svg-renderer';
class SVGSkin extends Skin {
/**

View File

@@ -1,337 +0,0 @@
// Synchronously load TTF fonts.
// First, have Webpack load their data as Base 64 strings.
/* eslint-disable global-require */
const FONTS = {
Donegal: require('base64-loader!scratch-render-fonts/DonegalOne-Regular.ttf'),
Gloria: require('base64-loader!scratch-render-fonts/GloriaHallelujah.ttf'),
Mystery: require('base64-loader!scratch-render-fonts/MysteryQuest-Regular.ttf'),
Marker: require('base64-loader!scratch-render-fonts/PermanentMarker.ttf'),
Scratch: require('base64-loader!scratch-render-fonts/Scratch.ttf')
};
/* eslint-enable global-require */
// For each Base 64 string,
// 1. Replace each with a usable @font-face tag that points to a Data URI.
// 2. Inject the font into a style on `document.body`, so measurements
// can be accurately taken in SvgRenderer._transformMeasurements.
const documentStyleTag = document.createElement('style');
documentStyleTag.id = 'scratch-font-styles';
for (const fontName in FONTS) {
const fontData = FONTS[fontName];
FONTS[fontName] = '@font-face {' +
`font-family: "${fontName}";src: url("data:application/x-font-ttf;charset=utf-8;base64,${fontData}");}`;
documentStyleTag.textContent += FONTS[fontName];
}
document.body.insertBefore(documentStyleTag, document.body.firstChild);
/**
* 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) {
this._canvas = canvas || document.createElement('canvas');
this._context = this._canvas.getContext('2d');
this._measurements = {x: 0, y: 0, width: 0, height: 0};
}
/**
* @returns {!HTMLCanvasElement} this renderer's target canvas.
*/
get canvas () {
return this._canvas;
}
/**
* Load an SVG from a string and draw it.
* This will be parsed and transformed, and finally drawn.
* When drawing is finished, the `onFinish` callback is called.
* @param {string} svgString String of SVG data to draw in quirks-mode.
* @param {Function} [onFinish] Optional callback for when drawing finished.
*/
fromString (svgString, onFinish) {
// Store the callback for later.
this._onFinish = onFinish;
this._loadString(svgString);
// Draw to a canvas.
this._draw();
}
/**
* 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.
*/
_loadString (svgString) {
// Parse string into SVG XML.
const parser = new DOMParser();
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;
// Transform all text elements.
this._transformText();
// Transform measurements.
this._transformMeasurements();
}
/**
* 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);
// For each text element, apply quirks.
const fontsNeeded = {};
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');
// If there's no font size provided, provide one.
if (!textElement.getAttribute('font-size')) {
textElement.setAttribute('font-size', '14');
}
// If there's no font-family provided, provide one.
if (!textElement.getAttribute('font-family')) {
textElement.setAttribute('font-family', 'Helvetica');
}
// Collect fonts that need injection.
const font = textElement.getAttribute('font-family');
fontsNeeded[font] = true;
// Fix line breaks in text, which are not natively supported by SVG.
let text = textElement.textContent;
if (text) {
textElement.textContent = '';
const lines = text.split('\n');
text = '';
for (const line of lines) {
const tspanNode = this._createSVGElement('tspan');
tspanNode.setAttribute('x', '0');
tspanNode.setAttribute('dy', '1.2em');
tspanNode.textContent = line;
textElement.appendChild(tspanNode);
}
}
}
// 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.
const newDefs = this._createSVGElement('defs');
const newStyle = this._createSVGElement('style');
const allFonts = Object.keys(fontsNeeded);
for (const font of allFonts) {
if (FONTS.hasOwnProperty(font)) {
newStyle.textContent += FONTS[font];
}
}
newDefs.appendChild(newStyle);
this._svgTag.insertBefore(newDefs, this._svgTag.childNodes[0]);
}
/**
* 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;
}
/**
* 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 () {
// Save `svgText` for later re-parsing.
const svgText = this._toString();
// 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');
let bbox;
try {
document.body.appendChild(svgSpot);
svgSpot.appendChild(this._svgTag);
// Take the bounding box.
bbox = this._svgTag.getBBox();
} finally {
// Always destroy the element, even if, for example, getBBox throws.
document.body.removeChild(svgSpot);
}
// Re-parse the SVG from `svgText`. The above DOM becomes
// unusable/undrawable in browsers once it's appended to the page,
// perhaps for security reasons?
const parser = new DOMParser();
this._svgDom = parser.parseFromString(svgText, 'text/xml');
this._svgTag = this._svgDom.documentElement;
// 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.
const halfStrokeWidth = this._findLargestStrokeWidth(this._svgTag) / 2;
bbox.width += halfStrokeWidth * 2;
bbox.height += halfStrokeWidth * 2;
bbox.x -= halfStrokeWidth;
bbox.y -= halfStrokeWidth;
// Set the correct measurements on the SVG tag, and save them.
this._svgTag.setAttribute('width', bbox.width);
this._svgTag.setAttribute('height', bbox.height);
this._svgTag.setAttribute('viewBox',
`${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`);
this._measurements = bbox;
}
/**
* Serialize the active SVG DOM to a string.
* @returns {string} String representing current SVG data.
*/
_toString () {
const serializer = new XMLSerializer();
return serializer.serializeToString(this._svgDom);
}
/**
* Get the drawing ratio, adjusted for HiDPI screens.
* @return {number} Scale ratio to draw to canvases with.
*/
getDrawRatio () {
const devicePixelRatio = window.devicePixelRatio || 1;
const backingStoreRatio = this._context.webkitBackingStorePixelRatio ||
this._context.mozBackingStorePixelRatio ||
this._context.msBackingStorePixelRatio ||
this._context.oBackingStorePixelRatio ||
this._context.backingStorePixelRatio || 1;
return devicePixelRatio / backingStoreRatio;
}
/**
* Draw the SVG to a canvas.
*/
_draw () {
const ratio = this.getDrawRatio();
const bbox = this._measurements;
// Convert the SVG text to an Image, and then draw it to the canvas.
const img = new Image();
img.onload = () => {
// Set up the canvas for drawing.
this._canvas.width = bbox.width * ratio;
this._canvas.height = bbox.height * ratio;
this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._context.scale(ratio, ratio);
this._context.drawImage(img, 0, 0);
// Reset the canvas transform after drawing.
this._context.setTransform(1, 0, 0, 1, 0, 0);
// Set the CSS style of the canvas to the actual measurements.
this._canvas.style.width = bbox.width;
this._canvas.style.height = bbox.height;
// All finished - call the callback if provided.
if (this._onFinish) {
this._onFinish();
}
};
const svgText = this._toString();
img.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
}
/**
* Helper to create an SVG element with the correct NS.
* @param {string} tagName Tag name for the element.
* @return {!DOMElement} Element created.
*/
_createSVGElement (tagName) {
return document.createElementNS(
'http://www.w3.org/2000/svg', tagName
);
}
}
module.exports = SvgRenderer;

View File

@@ -1,5 +1,6 @@
const SVGTextWrapper = require('./svg-text-wrapper');
const SVGRenderer = require('../svg-quirks-mode/svg-renderer');
import SVGRenderer from 'scratch-svg-renderer';
const MAX_LINE_LENGTH = 170;
const MIN_WIDTH = 50;