Repository: chanind/hanzi-writer
Pull request #114 (2019-06-09 20:25:37)

Rendering refactor in preparation for Wechat Miniprogram support
This PR refactors the core `HanziWriter` rendering classes to make it easier to support a separate Wechat miniprogram plugin in a separate repo in the near future.


Patch:
diff --git a/src/HanziWriter.js b/src/HanziWriter.js
index 27aaefe1..08bb2696 100644
--- a/src/HanziWriter.js
+++ b/src/HanziWriter.js
@@ -54,6 +54,7 @@ const defaultOptions = {
   drawingWidth: 4,
   strokeWidth: 2,
   outlineWidth: 2,
+  rendererOverride: {},
 };
 
 function HanziWriter(...args) {
@@ -209,7 +210,7 @@ HanziWriter.prototype.setCharacter = function(char) {
     this._renderState = new RenderState(this._character, this._options, (nextState) => {
       hanziWriterRenderer.render(nextState);
     });
-    this._hanziWriterRenderer.mount(this._target, this._renderState.state);
+    this._hanziWriterRenderer.mount(this.target, this._renderState.state);
     this._hanziWriterRenderer.render(this._renderState.state);
   });
   return this._withDataPromise;
@@ -218,11 +219,14 @@ HanziWriter.prototype.setCharacter = function(char) {
 // ------------- //
 
 HanziWriter.prototype._init = function(element, options) {
-  this._renderer = options.renderer === 'canvas' ? canvasRenderer : svgRenderer;
-  this._target = this._renderer.RenderTarget.init(element, options.width, options.height);
-  if (options.renderer !== 'canvas' && this._target.node.createSVGPoint) {
-    this._pt = this._target.node.createSVGPoint();
-  }
+  const renderer = options.renderer === 'canvas' ? canvasRenderer : svgRenderer;
+  const rendererOverride = options.rendererOverride || {};
+  this._renderer = {
+    HanziWriterRenderer: rendererOverride.HanziWriterRenderer || renderer.HanziWriterRenderer,
+    createRenderTarget: rendererOverride.createRenderTarget || renderer.createRenderTarget,
+  };
+  // wechat miniprogram component needs direct access to the render target, so this is public
+  this.target = this._renderer.createRenderTarget(element, options.width, options.height);
   this._options = this._assignOptions(options);
   this._loadingManager = new LoadingManager(this._options);
   this._setupListeners();
@@ -256,7 +260,7 @@ HanziWriter.prototype._fillWidthAndHeight = function(options) {
   } else if (filledOpts.height && !filledOpts.width) {
     filledOpts.width = filledOpts.height;
   } else if (!filledOpts.width && !filledOpts.height) {
-    const { width, height } = this._target.node.getBoundingClientRect();
+    const { width, height } = this.target.getBoundingClientRect();
     const minDim = Math.min(width, height);
     filledOpts.width = minDim;
     filledOpts.height = minDim;
@@ -277,30 +281,17 @@ HanziWriter.prototype._withData = function(func) {
 };
 
 HanziWriter.prototype._setupListeners = function() {
-  this._target.node.addEventListener('mousedown', (evt) => {
-    if (this.isLoadingCharData || !this._quiz) return;
-    evt.preventDefault();
-    this._forwardToQuiz('startUserStroke', this._getMousePoint(evt));
-  });
-  this._target.node.addEventListener('touchstart', (evt) => {
-    if (this.isLoadingCharData || !this._quiz) return;
-    evt.preventDefault();
-    this._forwardToQuiz('startUserStroke', this._getTouchPoint(evt));
-  });
-  this._target.node.addEventListener('mousemove', (evt) => {
+  this.target.addPointerStartListener((evt) => {
     if (this.isLoadingCharData || !this._quiz) return;
     evt.preventDefault();
-    this._forwardToQuiz('continueUserStroke', this._getMousePoint(evt));
+    this._forwardToQuiz('startUserStroke', evt.getPoint());
   });
-  this._target.node.addEventListener('touchmove', (evt) => {
+  this.target.addPointerMoveListener((evt) => {
     if (this.isLoadingCharData || !this._quiz) return;
     evt.preventDefault();
-    this._forwardToQuiz('continueUserStroke', this._getTouchPoint(evt));
+    this._forwardToQuiz('continueUserStroke', evt.getPoint());
   });
-
-  // TODO: fix
-  global.document.addEventListener('mouseup', () => this._forwardToQuiz('endUserStroke'));
-  global.document.addEventListener('touchend', () => this._forwardToQuiz('endUserStroke'));
+  this.target.addPointerEndListener(() => this._forwardToQuiz('endUserStroke'));
 };
 
 HanziWriter.prototype._forwardToQuiz = function(method, ...args) {
@@ -308,34 +299,6 @@ HanziWriter.prototype._forwardToQuiz = function(method, ...args) {
   this._quiz[method](...args);
 };
 
-HanziWriter.prototype._getMousePoint = function(evt) {
-  if (this._pt) {
-    this._pt.x = evt.clientX;
-    this._pt.y = evt.clientY;
-    const localPt = this._pt.matrixTransform(this._target.node.getScreenCTM().inverse());
-    return {x: localPt.x, y: localPt.y};
-  }
-  // fallback in case SVG matrix transforms aren't supported
-  const box = this._target.node.getBoundingClientRect();
-  const x = evt.clientX - box.left;
-  const y = evt.clientY - box.top;
-  return {x, y};
-};
-
-HanziWriter.prototype._getTouchPoint = function(evt) {
-  if (this._pt) {
-    this._pt.x = evt.touches[0].clientX;
-    this._pt.y = evt.touches[0].clientY;
-    const localPt = this._pt.matrixTransform(this._target.node.getScreenCTM().inverse());
-    return {x: localPt.x, y: localPt.y};
-  }
-  // fallback in case SVG matrix transforms aren't supported
-  const box = this._target.node.getBoundingClientRect();
-  const x = evt.touches[0].clientX - box.left;
-  const y = evt.touches[0].clientY - box.top;
-  return {x, y};
-};
-
 // --- Static Public API --- //
 
 HanziWriter.create = (element, character, options = {}) => {
diff --git a/src/defaultCharDataLoader.js b/src/defaultCharDataLoader.js
index dcb4823f..3c6f9b60 100644
--- a/src/defaultCharDataLoader.js
+++ b/src/defaultCharDataLoader.js
@@ -1,6 +1,3 @@
-// corresponds to the integer in the gh-pages branch under the cdn folder
-// make sure to check out a new version of the master branch in gh-pages when changing the data format
-// otherwise this may break any existing hanzi-writer deploys in the wild
 const VERSION = '2.0';
 const getCharDataUrl = (char) => `https://cdn.jsdelivr.net/npm/hanzi-writer-data@${VERSION}/${char}.json`;
 
diff --git a/src/renderers/RenderTargetBase.js b/src/renderers/RenderTargetBase.js
new file mode 100644
index 00000000..9968c162
--- /dev/null
+++ b/src/renderers/RenderTargetBase.js
@@ -0,0 +1,54 @@
+function RenderTargetBase() {}
+
+RenderTargetBase.prototype.addPointerStartListener = function(callback) {
+  this.node.addEventListener('mousedown', (evt) => {
+    callback(this._eventify(evt, this._getMousePoint));
+  });
+  this.node.addEventListener('touchstart', (evt) => {
+    callback(this._eventify(evt, this._getTouchPoint));
+  });
+};
+
+RenderTargetBase.prototype.addPointerMoveListener = function(callback) {
+  this.node.addEventListener('mousemove', (evt) => {
+    callback(this._eventify(evt, this._getMousePoint));
+  });
+  this.node.addEventListener('touchmove', (evt) => {
+    callback(this._eventify(evt, this._getTouchPoint));
+  });
+};
+
+RenderTargetBase.prototype.addPointerEndListener = function(callback) {
+  // TODO: find a way to not need global listeners
+  global.document.addEventListener('mouseup', callback);
+  global.document.addEventListener('touchend', callback);
+};
+
+
+RenderTargetBase.prototype.getBoundingClientRect = function() {
+  return this.node.getBoundingClientRect();
+};
+
+RenderTargetBase.prototype._eventify = function(evt, pointFunc) {
+  return {
+    getPoint: () => pointFunc.call(this, evt),
+    preventDefault: () => evt.preventDefault(),
+  };
+};
+
+RenderTargetBase.prototype._getMousePoint = function(evt) {
+  const box = this.getBoundingClientRect();
+  const x = evt.clientX - box.left;
+  const y = evt.clientY - box.top;
+  return {x, y};
+};
+
+RenderTargetBase.prototype._getTouchPoint = function(evt) {
+  const box = this.getBoundingClientRect();
+  const x = evt.touches[0].clientX - box.left;
+  const y = evt.touches[0].clientY - box.top;
+  return {x, y};
+};
+
+
+module.exports = RenderTargetBase;
diff --git a/src/renderers/StrokeRendererBase.js b/src/renderers/StrokeRendererBase.js
new file mode 100644
index 00000000..fbd86ef4
--- /dev/null
+++ b/src/renderers/StrokeRendererBase.js
@@ -0,0 +1,11 @@
+function StrokeRendererBase() {}
+
+StrokeRendererBase.prototype._getStrokeDashoffset = function(displayPortion) {
+  return this._pathLength * 0.999 * (1 - displayPortion);
+};
+
+StrokeRendererBase.prototype._getColor = function({ strokeColor, radicalColor }) {
+  return radicalColor && this._stroke.isInRadical ? radicalColor : strokeColor;
+};
+
+module.exports = StrokeRendererBase;
diff --git a/src/renderers/canvas/HanziWriterRenderer.js b/src/renderers/canvas/HanziWriterRenderer.js
index 1fda6414..351c511f 100644
--- a/src/renderers/canvas/HanziWriterRenderer.js
+++ b/src/renderers/canvas/HanziWriterRenderer.js
@@ -15,7 +15,7 @@ HanziWriterRenderer.prototype.mount = function(target) {
 };
 
 HanziWriterRenderer.prototype._animationFrame = function(func) {
-  const ctx = this._target.node.getContext('2d');
+  const ctx = this._target.getContext();
   ctx.clearRect(0, 0, this._positioner.width, this._positioner.height);
 
   ctx.save();
@@ -23,6 +23,7 @@ HanziWriterRenderer.prototype._animationFrame = function(func) {
   ctx.scale(this._positioner.scale, -1 * this._positioner.scale);
   func(ctx);
   ctx.restore();
+  if (ctx.draw) ctx.draw();
 };
 
 HanziWriterRenderer.prototype.render = function(props) {
diff --git a/src/renderers/canvas/RenderTarget.js b/src/renderers/canvas/RenderTarget.js
index 8a12cc7d..d7d7a2ad 100644
--- a/src/renderers/canvas/RenderTarget.js
+++ b/src/renderers/canvas/RenderTarget.js
@@ -1,6 +1,13 @@
+const RenderTargetBase = require('../RenderTargetBase');
+
 function RenderTarget(canvas) {
   this.node = canvas;
 }
+RenderTarget.prototype = Object.create(RenderTargetBase.prototype);
+
+RenderTarget.prototype.getContext = function() {
+  return this.node.getContext('2d');
+};
 
 RenderTarget.init = (elmOrId, width = '100%', height = '100%') => {
   let canvas;
diff --git a/src/renderers/canvas/StrokeRenderer.js b/src/renderers/canvas/StrokeRenderer.js
index 1b3fe66f..64f7f70b 100644
--- a/src/renderers/canvas/StrokeRenderer.js
+++ b/src/renderers/canvas/StrokeRenderer.js
@@ -1,43 +1,51 @@
 const { extendStart } = require('../../geometry');
-const { drawPath } = require('./canvasUtils');
+const { drawPath, pathStringToCanvas } = require('./canvasUtils');
+const StrokeRendererBase = require('../StrokeRendererBase');
 
 const STROKE_WIDTH = 200;
 
 // this is a stroke composed of several stroke parts
-function StrokeRenderer(stroke) {
+function StrokeRenderer(stroke, usePath2D = true) {
   this._stroke = stroke;
   this._pathLength = stroke.getLength() + (STROKE_WIDTH / 2);
-  this._path2D = new global.Path2D(this._stroke.path);
+  if (usePath2D && global.Path2D) {
+    this._path2D = new global.Path2D(this._stroke.path);
+  } else {
+    this._pathCmd = pathStringToCanvas(this._stroke.path);
+  }
   this._extendedMaskPoints = extendStart(this._stroke.points, STROKE_WIDTH / 2);
 }
+StrokeRenderer.prototype = Object.create(StrokeRendererBase.prototype);
 
 StrokeRenderer.prototype.render = function(ctx, props) {
   if (props.opacity < 0.05) return;
 
   ctx.save();
-  ctx.clip(this._path2D);
+  if (this._path2D) {
+    ctx.clip(this._path2D);
+  } else {
+    this._pathCmd(ctx);
+    // wechat bugs out if the clip path isn't stroked or filled
+    ctx.globalAlpha = 0;
+    ctx.stroke();
+    ctx.clip();
+  }
 
   const { r, g, b, a } = this._getColor(props);
-  const color = `rgba(${r},${g},${b},${a})`;
+  const color = a === 1 ? `rgb(${r},${g},${b})` : `rgb(${r},${g},${b},${a})`;
+  const dashOffset = this._getStrokeDashoffset(props.displayPortion);
   ctx.globalAlpha = props.opacity;
   ctx.strokeStyle = color;
   ctx.fillStyle = color;
   ctx.lineWidth = STROKE_WIDTH;
   ctx.lineCap = 'round';
   ctx.lineJoin = 'round';
-  ctx.setLineDash([this._pathLength, this._pathLength]);
-  ctx.lineDashOffset = this._getStrokeDashoffset(props.displayPortion);
+  // wechat sets dashOffset as a second param here. Should be harmless for browsers to add here too
+  ctx.setLineDash([this._pathLength, this._pathLength], dashOffset);
+  ctx.lineDashOffset = dashOffset;
   drawPath(ctx, this._extendedMaskPoints);
 
   ctx.restore();
 };
 
-StrokeRenderer.prototype._getStrokeDashoffset = function(displayPortion) {
-  return this._pathLength * 0.999 * (1 - displayPortion);
-};
-
-StrokeRenderer.prototype._getColor = function({ strokeColor, radicalColor }) {
-  return radicalColor && this._stroke.isInRadical ? radicalColor : strokeColor;
-};
-
 module.exports = StrokeRenderer;
diff --git a/src/renderers/canvas/canvasUtils.js b/src/renderers/canvas/canvasUtils.js
index c59c8df5..578b7fd7 100644
--- a/src/renderers/canvas/canvasUtils.js
+++ b/src/renderers/canvas/canvasUtils.js
@@ -9,4 +9,27 @@ const drawPath = (ctx, points) => {
   ctx.stroke();
 };
 
-module.exports = { drawPath };
+// break a path string into a series of canvas path commands
+// only works with the subset of SVG paths used by MakeMeAHanzi data
+const pathStringToCanvas = (pathString) => {
+  const pathParts = pathString.split(/(^|\s+)(?=[A-Z])/).filter(part => part !== ' ');
+  const commands = [(ctx) => ctx.beginPath()];
+  pathParts.forEach(part => {
+    const [cmd, ...rawParams] = part.split(/\s+/);
+    const params = rawParams.map(param => parseFloat(param));
+    if (cmd === 'M') {
+      commands.push((ctx) => ctx.moveTo.apply(ctx, params));
+    } else if (cmd === 'L') {
+      commands.push((ctx) => ctx.lineTo.apply(ctx, params));
+    } else if (cmd === 'C') {
+      commands.push((ctx) => ctx.bezierCurveTo.apply(ctx, params));
+    } else if (cmd === 'Q') {
+      commands.push((ctx) => ctx.quadraticCurveTo.apply(ctx, params));
+    } else if (cmd === 'Z') {
+      // commands.push((ctx) => ctx.closePath());
+    }
+  });
+  return (ctx) => commands.forEach(cmd => cmd(ctx));
+};
+
+module.exports = { drawPath, pathStringToCanvas };
diff --git a/src/renderers/canvas/index.js b/src/renderers/canvas/index.js
index 5dac206a..7edd2395 100644
--- a/src/renderers/canvas/index.js
+++ b/src/renderers/canvas/index.js
@@ -3,5 +3,5 @@ const RenderTarget = require('./RenderTarget');
 
 module.exports = {
   HanziWriterRenderer,
-  RenderTarget,
+  createRenderTarget: RenderTarget.init,
 };
diff --git a/src/renderers/svg/RenderTarget.js b/src/renderers/svg/RenderTarget.js
index e760db4b..c3eed056 100644
--- a/src/renderers/svg/RenderTarget.js
+++ b/src/renderers/svg/RenderTarget.js
@@ -1,10 +1,16 @@
 const { createElm, attrs } = require('./svgUtils');
+const RenderTargetBase = require('../RenderTargetBase');
 
 function RenderTarget(svg, defs) {
   this.svg = svg;
   this.defs = defs;
   this.node = svg;
+
+  if (this.node.createSVGPoint) {
+    this._pt = this.node.createSVGPoint();
+  }
 }
+RenderTarget.prototype = Object.create(RenderTargetBase.prototype);
 
 RenderTarget.prototype.createSubRenderTarget = function() {
   const group = createElm('g');
@@ -12,6 +18,26 @@ RenderTarget.prototype.createSubRenderTarget = function() {
   return new RenderTarget(group, this.defs);
 };
 
+RenderTarget.prototype._getMousePoint = function(evt) {
+  if (this._pt) {
+    this._pt.x = evt.clientX;
+    this._pt.y = evt.clientY;
+    const localPt = this._pt.matrixTransform(this._target.node.getScreenCTM().inverse());
+    return {x: localPt.x, y: localPt.y};
+  }
+  return RenderTargetBase.prototype._getMousePoint.call(this, evt);
+};
+
+RenderTarget.prototype._getTouchPoint = function(evt) {
+  if (this._pt) {
+    this._pt.x = evt.touches[0].clientX;
+    this._pt.y = evt.touches[0].clientY;
+    const localPt = this._pt.matrixTransform(this._target.node.getScreenCTM().inverse());
+    return {x: localPt.x, y: localPt.y};
+  }
+  return RenderTargetBase.prototype._getTouchPoint.call(this, evt);
+};
+
 RenderTarget.init = (elmOrId, width = '100%', height = '100%') => {
   let svg;
   let elm = elmOrId;
diff --git a/src/renderers/svg/StrokeRenderer.js b/src/renderers/svg/StrokeRenderer.js
index 15488ad0..ff029617 100644
--- a/src/renderers/svg/StrokeRenderer.js
+++ b/src/renderers/svg/StrokeRenderer.js
@@ -4,6 +4,7 @@ const {
   extendStart,
   getPathString,
 } = require('../../geometry');
+const StrokeRendererBase = require('../StrokeRendererBase');
 
 const STROKE_WIDTH = 200;
 
@@ -13,6 +14,7 @@ function StrokeRenderer(stroke) {
   this._stroke = stroke;
   this._pathLength = stroke.getLength() + (STROKE_WIDTH / 2);
 }
+StrokeRenderer.prototype = Object.create(StrokeRendererBase.prototype);
 
 StrokeRenderer.prototype.mount = function(target) {
   this._animationPath = svg.createElm('path');
@@ -60,12 +62,4 @@ StrokeRenderer.prototype.render = function(props) {
   this._oldProps = props;
 };
 
-StrokeRenderer.prototype._getStrokeDashoffset = function(displayPortion) {
-  return this._pathLength * 0.999 * (1 - displayPortion);
-};
-
-StrokeRenderer.prototype._getColor = function({ strokeColor, radicalColor }) {
-  return radicalColor && this._stroke.isInRadical ? radicalColor : strokeColor;
-};
-
 module.exports = StrokeRenderer;
diff --git a/src/renderers/svg/index.js b/src/renderers/svg/index.js
index 5dac206a..7edd2395 100644
--- a/src/renderers/svg/index.js
+++ b/src/renderers/svg/index.js
@@ -3,5 +3,5 @@ const RenderTarget = require('./RenderTarget');
 
 module.exports = {
   HanziWriterRenderer,
-  RenderTarget,
+  createRenderTarget: RenderTarget.init,
 };


Test patch:
diff --git a/demo/test.js b/demo/test.js
index 44ad977b..f920c1a3 100644
--- a/demo/test.js
+++ b/demo/test.js
@@ -15,6 +15,7 @@ function updateCharacter() {
 	writer = HanziWriter.create('target', character, {
 		width: 400,
 		height: 400,
+		renderer: 'canvas',
 		radicalColor: '#166E16',
 		onCorrectStroke: printStrokePoints,
 		onMistake: printStrokePoints,
diff --git a/src/__tests__/__snapshots__/HanziWriter-test.js.snap b/src/__tests__/__snapshots__/HanziWriter-test.js.snap
index 1dacbabe..4745f01d 100644
--- a/src/__tests__/__snapshots__/HanziWriter-test.js.snap
+++ b/src/__tests__/__snapshots__/HanziWriter-test.js.snap
@@ -3284,24 +3284,28 @@ CanvasRenderingContext2D {
           918.3825419953906,
           918.3825419953906,
         ],
+        0,
       ],
       Array [
         Array [
           779.7155627376544,
           779.7155627376544,
         ],
+        0,
       ],
       Array [
         Array [
           918.3825419953906,
           918.3825419953906,
         ],
+        0,
       ],
       Array [
         Array [
           779.7155627376544,
           779.7155627376544,
         ],
+        0,
       ],
     ],
     "results": Array [
diff --git a/src/renderers/canvas/__tests__/StrokeRenderer-test.js b/src/renderers/canvas/__tests__/StrokeRenderer-test.js
index d0c15a43..bd93e37b 100644
--- a/src/renderers/canvas/__tests__/StrokeRenderer-test.js
+++ b/src/renderers/canvas/__tests__/StrokeRenderer-test.js
@@ -27,6 +27,20 @@ describe('StrokeRenderer', () => {
     expect(ctx.__getEvents()).toMatchSnapshot();
   });
 
+  it('works without using Path2D if needed', () => {
+    const props = {
+      strokeColor: {r: 12, g: 101, b: 20, a: 1},
+      radicalColor: null,
+      strokeWidth: 2,
+      opacity: 0.7,
+      displayPortion: 0.4,
+    };
+    const renderer = new StrokeRenderer(char.strokes[0], false);
+    renderer.render(ctx, props);
+
+    expect(ctx.__getEvents()).toMatchSnapshot();
+  });
+
   it('skips rendering if opacity is close to 0', () => {
     const props = {
       strokeColor: {r: 12, g: 101, b: 20, a: 0.3},
diff --git a/src/renderers/canvas/__tests__/__snapshots__/StrokeRenderer-test.js.snap b/src/renderers/canvas/__tests__/__snapshots__/StrokeRenderer-test.js.snap
index 8f6f89bf..bc64d3ff 100644
--- a/src/renderers/canvas/__tests__/__snapshots__/StrokeRenderer-test.js.snap
+++ b/src/renderers/canvas/__tests__/__snapshots__/StrokeRenderer-test.js.snap
@@ -347,3 +347,717 @@ Array [
   },
 ]
 `;
+
+exports[`StrokeRenderer works without using Path2D if needed 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "save",
+  },
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 518,
+      "y": 382,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 572,
+      "cpy": 385,
+      "x": 623,
+      "y": 389,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 758,
+      "cpy": 399,
+      "x": 900,
+      "y": 383,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 928,
+      "cpy": 379,
+      "x": 935,
+      "y": 390,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 944,
+      "cpy": 405,
+      "x": 930,
+      "y": 419,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 896,
+      "cpy": 452,
+      "x": 845,
+      "y": 475,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 829,
+      "cpy": 482,
+      "x": 798,
+      "y": 473,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 723,
+      "cpy": 460,
+      "x": 480,
+      "y": 434,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 180,
+      "cpy": 409,
+      "x": 137,
+      "y": 408,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 130,
+      "cpy": 408,
+      "x": 124,
+      "y": 408,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 108,
+      "cpy": 408,
+      "x": 106,
+      "y": 395,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 105,
+      "cpy": 380,
+      "x": 127,
+      "y": 363,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 146,
+      "cpy": 348,
+      "x": 183,
+      "y": 334,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 195,
+      "cpy": 330,
+      "x": 216,
+      "y": 338,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 232,
+      "cpy": 344,
+      "x": 306,
+      "y": 354,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 400,
+      "cpy": 373,
+      "x": 518,
+      "y": 382,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "value": 0,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "globalAlpha",
+  },
+  Object {
+    "props": Object {
+      "path": Array [
+        Object {
+          "props": Object {},
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "beginPath",
+        },
+        Object {
+          "props": Object {
+            "x": 518,
+            "y": 382,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "moveTo",
+        },
+      ],
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "stroke",
+  },
+  Object {
+    "props": Object {
+      "fillRule": "nonzero",
+      "path": Array [
+        Object {
+          "props": Object {},
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "beginPath",
+        },
+        Object {
+          "props": Object {
+            "x": 518,
+            "y": 382,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "moveTo",
+        },
+      ],
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "clip",
+  },
+  Object {
+    "props": Object {
+      "value": 0.7,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "globalAlpha",
+  },
+  Object {
+    "props": Object {
+      "value": "#0c6514",
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "strokeStyle",
+  },
+  Object {
+    "props": Object {
+      "value": "#0c6514",
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "fillStyle",
+  },
+  Object {
+    "props": Object {
+      "value": 200,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineWidth",
+  },
+  Object {
+    "props": Object {
+      "value": "round",
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineCap",
+  },
+  Object {
+    "props": Object {
+      "value": "round",
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineJoin",
+  },
+  Object {
+    "props": Object {
+      "value": Array [
+        910.9281897070704,
+        910.9281897070704,
+      ],
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "setLineDash",
+  },
+  Object {
+    "props": Object {
+      "value": 546.010356910418,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineDashOffset",
+  },
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 25,
+      "y": 421,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 193,
+      "y": 372,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "x": 417,
+      "y": 402,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "x": 827,
+      "y": 434,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "x": 920,
+      "y": 401,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "path": Array [
+        Object {
+          "props": Object {},
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "beginPath",
+        },
+        Object {
+          "props": Object {
+            "x": 25,
+            "y": 421,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "moveTo",
+        },
+        Object {
+          "props": Object {
+            "x": 193,
+            "y": 372,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "lineTo",
+        },
+        Object {
+          "props": Object {
+            "x": 417,
+            "y": 402,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "lineTo",
+        },
+        Object {
+          "props": Object {
+            "x": 827,
+            "y": 434,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "lineTo",
+        },
+        Object {
+          "props": Object {
+            "x": 920,
+            "y": 401,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "lineTo",
+        },
+      ],
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "stroke",
+  },
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "restore",
+  },
+]
+`;
diff --git a/src/renderers/canvas/__tests__/__snapshots__/canvasUtils-test.js.snap b/src/renderers/canvas/__tests__/__snapshots__/canvasUtils-test.js.snap
new file mode 100644
index 00000000..be254ff9
--- /dev/null
+++ b/src/renderers/canvas/__tests__/__snapshots__/canvasUtils-test.js.snap
@@ -0,0 +1,1978 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 0 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 350,
+      "y": 571,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 380,
+      "cpy": 593,
+      "x": 449,
+      "y": 614,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 465,
+      "cpy": 615,
+      "x": 468,
+      "y": 623,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 471,
+      "cpy": 633,
+      "x": 458,
+      "y": 643,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 439,
+      "cpy": 656,
+      "x": 396,
+      "y": 668,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 381,
+      "cpy": 674,
+      "x": 370,
+      "y": 672,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 363,
+      "cpy": 668,
+      "x": 363,
+      "y": 657,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 364,
+      "cpy": 621,
+      "x": 200,
+      "y": 527,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 196,
+      "cpy": 518,
+      "x": 201,
+      "y": 516,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 213,
+      "cpy": 516,
+      "x": 290,
+      "y": 546,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 303,
+      "cpy": 550,
+      "x": 316,
+      "y": 556,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 350,
+      "y": 571,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 1 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 584,
+      "y": 466,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 666,
+      "cpy": 485,
+      "x": 734,
+      "y": 497,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 746,
+      "cpy": 496,
+      "x": 754,
+      "y": 511,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 755,
+      "cpy": 524,
+      "x": 729,
+      "y": 533,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 693,
+      "cpy": 554,
+      "x": 622,
+      "y": 527,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 598,
+      "cpy": 520,
+      "x": 575,
+      "y": 511,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 537,
+      "y": 499,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 518,
+      "cpy": 495,
+      "x": 500,
+      "y": 488,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 442,
+      "cpy": 472,
+      "x": 386,
+      "y": 457,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 337,
+      "y": 446,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 327,
+      "cpy": 446,
+      "x": 179,
+      "y": 416,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 148,
+      "cpy": 409,
+      "x": 173,
+      "y": 392,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 212,
+      "cpy": 365,
+      "x": 241,
+      "y": 376,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 287,
+      "cpy": 389,
+      "x": 339,
+      "y": 404,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 387,
+      "y": 416,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 460,
+      "cpy": 438,
+      "x": 545,
+      "y": 457,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 584,
+      "y": 466,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 2 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 386,
+      "y": 457,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 387,
+      "cpy": 493,
+      "x": 398,
+      "y": 517,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 405,
+      "cpy": 535,
+      "x": 390,
+      "y": 548,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 371,
+      "cpy": 564,
+      "x": 350,
+      "y": 571,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx1": 323,
+      "cpx2": 303,
+      "cpy1": 583,
+      "cpy2": 583,
+      "x": 316,
+      "y": 556,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "bezierCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 315,
+      "cpy": 556,
+      "x": 316,
+      "y": 555,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 338,
+      "cpy": 519,
+      "x": 337,
+      "y": 478,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 337,
+      "cpy": 462,
+      "x": 337,
+      "y": 446,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 339,
+      "y": 404,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 340,
+      "cpy": 343,
+      "x": 339,
+      "y": 289,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 338,
+      "y": 241,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 337,
+      "cpy": 180,
+      "x": 334,
+      "y": 133,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 333,
+      "cpy": 115,
+      "x": 323,
+      "y": 109,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 317,
+      "cpy": 105,
+      "x": 250,
+      "y": 119,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 238,
+      "cpy": 122,
+      "x": 239,
+      "y": 114,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 240,
+      "cpy": 108,
+      "x": 249,
+      "y": 100,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 309,
+      "cpy": 42,
+      "x": 328,
+      "y": 6,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 341,
+      "cpy": -10,
+      "x": 357,
+      "y": 3,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 390,
+      "cpy": 36,
+      "x": 390,
+      "y": 126,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 387,
+      "cpy": 169,
+      "x": 387,
+      "y": 265,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 387,
+      "y": 306,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 387,
+      "cpy": 355,
+      "x": 387,
+      "y": 416,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 386,
+      "y": 457,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 3 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 339,
+      "y": 289,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 254,
+      "cpy": 261,
+      "x": 161,
+      "y": 229,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 139,
+      "cpy": 222,
+      "x": 101,
+      "y": 221,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 86,
+      "cpy": 220,
+      "x": 85,
+      "y": 207,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 84,
+      "cpy": 192,
+      "x": 94,
+      "y": 184,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 119,
+      "cpy": 166,
+      "x": 157,
+      "y": 147,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 169,
+      "cpy": 144,
+      "x": 182,
+      "y": 154,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 239,
+      "cpy": 199,
+      "x": 338,
+      "y": 241,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 387,
+      "y": 265,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 477,
+      "cpy": 314,
+      "x": 484,
+      "y": 318,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 499,
+      "cpy": 327,
+      "x": 498,
+      "y": 337,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 492,
+      "cpy": 343,
+      "x": 479,
+      "y": 340,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 434,
+      "cpy": 324,
+      "x": 387,
+      "y": 306,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 339,
+      "y": 289,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 4 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 635,
+      "y": 195,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 690,
+      "cpy": 75,
+      "x": 797,
+      "y": -14,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 876,
+      "cpy": -62,
+      "x": 898,
+      "y": -47,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 920,
+      "cpy": -37,
+      "x": 914,
+      "y": 3,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 905,
+      "cpy": 34,
+      "x": 899,
+      "y": 152,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 900,
+      "cpy": 174,
+      "x": 894,
+      "y": 178,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 890,
+      "cpy": 179,
+      "x": 884,
+      "y": 160,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 857,
+      "cpy": 75,
+      "x": 838,
+      "y": 60,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 823,
+      "cpy": 56,
+      "x": 785,
+      "y": 88,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 710,
+      "cpy": 155,
+      "x": 670,
+      "y": 226,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 644,
+      "y": 279,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 599,
+      "cpy": 381,
+      "x": 584,
+      "y": 466,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 575,
+      "y": 511,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 547,
+      "cpy": 659,
+      "x": 576,
+      "y": 752,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 586,
+      "cpy": 779,
+      "x": 543,
+      "y": 805,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 509,
+      "cpy": 827,
+      "x": 489,
+      "y": 825,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 470,
+      "cpy": 824,
+      "x": 479,
+      "y": 795,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 503,
+      "cpy": 752,
+      "x": 507,
+      "y": 707,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 517,
+      "cpy": 601,
+      "x": 537,
+      "y": 499,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 545,
+      "y": 457,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 573,
+      "cpy": 334,
+      "x": 612,
+      "y": 245,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 635,
+      "y": 195,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 5 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 612,
+      "y": 245,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 558,
+      "cpy": 197,
+      "x": 452,
+      "y": 138,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 442,
+      "cpy": 132,
+      "x": 448,
+      "y": 128,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 455,
+      "cpy": 124,
+      "x": 468,
+      "y": 126,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 523,
+      "cpy": 135,
+      "x": 574,
+      "y": 160,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 608,
+      "cpy": 175,
+      "x": 635,
+      "y": 195,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 670,
+      "y": 226,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 706,
+      "cpy": 260,
+      "x": 747,
+      "y": 317,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 762,
+      "cpy": 336,
+      "x": 778,
+      "y": 354,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 788,
+      "cpy": 361,
+      "x": 785,
+      "y": 374,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 781,
+      "cpy": 386,
+      "x": 753,
+      "y": 410,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 734,
+      "cpy": 428,
+      "x": 723,
+      "y": 428,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 708,
+      "cpy": 427,
+      "x": 707,
+      "y": 411,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 701,
+      "cpy": 354,
+      "x": 644,
+      "y": 279,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "x": 612,
+      "y": 245,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "lineTo",
+  },
+]
+`;
+
+exports[`canvasUtils pathStringToCanvas translates SVG pathstrings from MakeMeAHanzi into canvas commands case 6 1`] = `
+Array [
+  Object {
+    "props": Object {},
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "beginPath",
+  },
+  Object {
+    "props": Object {
+      "x": 687,
+      "y": 669,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "moveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 718,
+      "cpy": 648,
+      "x": 754,
+      "y": 623,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 770,
+      "cpy": 613,
+      "x": 786,
+      "y": 615,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 798,
+      "cpy": 618,
+      "x": 801,
+      "y": 632,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 802,
+      "cpy": 648,
+      "x": 789,
+      "y": 678,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 780,
+      "cpy": 697,
+      "x": 746,
+      "y": 708,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 665,
+      "cpy": 726,
+      "x": 651,
+      "y": 715,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 647,
+      "cpy": 711,
+      "x": 651,
+      "y": 697,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+  Object {
+    "props": Object {
+      "cpx": 655,
+      "cpy": 687,
+      "x": 687,
+      "y": 669,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "quadraticCurveTo",
+  },
+]
+`;
diff --git a/src/renderers/canvas/__tests__/canvasUtils-test.js b/src/renderers/canvas/__tests__/canvasUtils-test.js
new file mode 100644
index 00000000..1e19d7f0
--- /dev/null
+++ b/src/renderers/canvas/__tests__/canvasUtils-test.js
@@ -0,0 +1,29 @@
+const { pathStringToCanvas } = require('../canvasUtils');
+
+describe('canvasUtils', () => {
+  let ctx;
+
+  beforeEach(() => {
+    ctx = document.createElement('canvas').getContext('2d');
+  });
+
+  describe('pathStringToCanvas', () => {
+    const pathStrings = [
+      'M 350 571 Q 380 593 449 614 Q 465 615 468 623 Q 471 633 458 643 Q 439 656 396 668 Q 381 674 370 672 Q 363 668 363 657 Q 364 621 200 527 Q 196 518 201 516 Q 213 516 290 546 Q 303 550 316 556 L 350 571 Z',
+      'M 584 466 Q 666 485 734 497 Q 746 496 754 511 Q 755 524 729 533 Q 693 554 622 527 Q 598 520 575 511 L 537 499 Q 518 495 500 488 Q 442 472 386 457 L 337 446 Q 327 446 179 416 Q 148 409 173 392 Q 212 365 241 376 Q 287 389 339 404 L 387 416 Q 460 438 545 457 L 584 466 Z',
+      'M 386 457 Q 387 493 398 517 Q 405 535 390 548 Q 371 564 350 571 C 323 583 303 583 316 556 Q 315 556 316 555 Q 338 519 337 478 Q 337 462 337 446 L 339 404 Q 340 343 339 289 L 338 241 Q 337 180 334 133 Q 333 115 323 109 Q 317 105 250 119 Q 238 122 239 114 Q 240 108 249 100 Q 309 42 328 6 Q 341 -10 357 3 Q 390 36 390 126 Q 387 169 387 265 L 387 306 Q 387 355 387 416 L 386 457 Z',
+      'M 339 289 Q 254 261 161 229 Q 139 222 101 221 Q 86 220 85 207 Q 84 192 94 184 Q 119 166 157 147 Q 169 144 182 154 Q 239 199 338 241 L 387 265 Q 477 314 484 318 Q 499 327 498 337 Q 492 343 479 340 Q 434 324 387 306 L 339 289 Z',
+      'M 635 195 Q 690 75 797 -14 Q 876 -62 898 -47 Q 920 -37 914 3 Q 905 34 899 152 Q 900 174 894 178 Q 890 179 884 160 Q 857 75 838 60 Q 823 56 785 88 Q 710 155 670 226 L 644 279 Q 599 381 584 466 L 575 511 Q 547 659 576 752 Q 586 779 543 805 Q 509 827 489 825 Q 470 824 479 795 Q 503 752 507 707 Q 517 601 537 499 L 545 457 Q 573 334 612 245 L 635 195 Z',
+      'M 612 245 Q 558 197 452 138 Q 442 132 448 128 Q 455 124 468 126 Q 523 135 574 160 Q 608 175 635 195 L 670 226 Q 706 260 747 317 Q 762 336 778 354 Q 788 361 785 374 Q 781 386 753 410 Q 734 428 723 428 Q 708 427 707 411 Q 701 354 644 279 L 612 245 Z',
+      'M 687 669 Q 718 648 754 623 Q 770 613 786 615 Q 798 618 801 632 Q 802 648 789 678 Q 780 697 746 708 Q 665 726 651 715 Q 647 711 651 697 Q 655 687 687 669 Z',
+    ];
+
+    pathStrings.forEach((pathString, index) => {
+      it(`translates SVG pathstrings from MakeMeAHanzi into canvas commands case ${index}`, () => {
+        const commandFunc = pathStringToCanvas(pathString);
+        commandFunc(ctx);
+        expect(ctx.__getEvents()).toMatchSnapshot();
+      });
+    });
+  });
+});