From f92ef3ffec0dd3553359861b80effa07c640bb02 Mon Sep 17 00:00:00 2001 From: Connum Date: Tue, 28 Feb 2023 23:06:41 +0100 Subject: [PATCH] Add SVG-related API improvements --- README.md | 32 ++++- bin/test-render | 29 ++-- src/glyph.js | 37 +++++ src/path.js | 362 +++++++++++++++++++++++++++++++++++++++++++++--- test/glyph.js | 32 +++++ test/path.js | 100 +++++++++++++ 6 files changed, 561 insertions(+), 31 deletions(-) create mode 100644 test/path.js diff --git a/README.md b/README.md index 107e36d3..5887873d 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,9 @@ Blue lines indicate the glyph bounding box. Green line indicates the advance width of the glyph. The arguments are the same as `Glyph.draw`. +##### `Glyph.toPathData(options)`, `Glyph.toDOMElement(options)`, `Glyph.toSVG(options)`, `Glyph.fromSVG(pathData, options)`, +These are currently only wrapper functions for their counterparts on Path objects (see documentation there), but may be extended in the future to pass on Glyph data for automatic calculation. + ### The Path object Once you have a path through `Font.getPath` or `Glyph.getPath`, you can use it. @@ -273,14 +276,35 @@ Draw the path on the given 2D context. This uses the `fill`, `stroke` and `strok Calculate the minimum bounding box for the given path. Returns an `opentype.BoundingBox` object that contains x1/y1/x2/y2. If the path is empty (e.g. a space character), all coordinates will be zero. -##### `Path.toPathData(decimalPlaces)` +##### `Path.toPathData(options)` Convert the Path to a string of path data instructions. See https://www.w3.org/TR/SVG/paths.html#PathData -* `decimalPlaces`: The amount of decimal places for floating-point values. (default: 2) +* `options`: + * `decimalPlaces`: The amount of decimal places for floating-point values. (default: 2) + * `optimize`: apply some optimizations to the path data, e.g. removing unnecessary/duplicate commands (true/false, default: true) + * `flipY`: whether to flip the Y axis of the path data, because SVG and font paths use inverted Y axes. (true: calculate from bounding box, false: disable; default: true) + * `flipYBase`: Base value for the base flipping calculation. You'll probably want to calculate this from the font's ascender and descender values. (default: automatically calculate from the path data's bounding box) + -##### `Path.toSVG(decimalPlaces)` +##### `Path.toSVG(options)` Convert the path to a SVG <path> element, as a string. -* `decimalPlaces`: The amount of decimal places for floating-point values. (default: 2) +* `options`: see Path.toPathData + +##### `Path.fromSVG(pathData, options)` +Retrieve path from SVG path data. Either overwriting the path data for an existing path +```js +const path = new Path(); +path.fromSVG('M0 0'); +``` +or creating a new Path directly: +```js +const path = Path.fromSVG('M0 0'); +``` +* `pathData`: Either a string of SVG path commands, or (only in browser context) an `SVGPathElement` +* `options`: + * `decimalPlaces`, `optimize`, `flipY`, `flipYBase`: see Path.toPathData + * `scale`: scaling value applied to all command coordinates (default: 1) + * `x`/`y`: offset applied to all command coordinates on the x or y axis (default: 0) #### Path commands * **Move To**: Move to a new position. This creates a new contour. Example: `{type: 'M', x: 100, y: 200}` diff --git a/bin/test-render b/bin/test-render index 4bfc31e6..f0ec59c1 100755 --- a/bin/test-render +++ b/bin/test-render @@ -60,29 +60,40 @@ function renderSVG() { let svgBody = []; var glyphSet = new Set(); - let x = 0; - const glyphs = font.stringToGlyphs(textToRender); - for (let i = 0; i < glyphs.length; i++) { - const glyph = glyphs[i]; + let glyphData = []; + + const fontSize = font.unitsPerEm; + let minWidth = 0; + font.forEachGlyph(textToRender, 0, 0, fontSize, {}, function(glyph, gX, gY, gFontSize) { + const glyphPath = glyph.getPath(gX, gY, gFontSize, {}, this); + const glyphWidth = glyph.getMetrics().xMax; + glyphData.push({glyph: glyph, path: glyphPath, gX, gY, w: glyphWidth}); + }); + + for (let i = 0; i < glyphData.length; i++) { + const glyph = glyphData[i].glyph; + const path = glyphData[i].path; const symbolId = testcase + '.' + glyph.name; if (!glyphSet.has(glyph)) { glyphSet.add(glyph); - const svgPath = glyph.path.toSVG(); + const svgPath = glyph.path.toSVG({optimize: true, decimalPlaces: 0, flipY: false}); svgSymbols.push(` ${svgPath}`); } - svgBody.push(` `); - x += glyph.advanceWidth; + svgBody.push(` `); + let xMax = glyphData[i].gX + glyph.advanceWidth; + if(xMax > minWidth) { + minWidth = xMax; + } } let minX = 0; let minY = Math.round(font.descender); - let width = Math.round(x); let height = Math.round(font.ascender - font.descender); let svgHeader = ` `; + viewBox="${minX} ${minY} ${minWidth} ${height}">`; return svgHeader + svgSymbols.join('\n') + svgBody.join('\n') + SVG_FOOTER; } diff --git a/src/glyph.js b/src/glyph.js index 13bdb42c..b48d067b 100644 --- a/src/glyph.js +++ b/src/glyph.js @@ -374,4 +374,41 @@ Glyph.prototype.drawMetrics = function(ctx, x, y, fontSize) { draw.line(ctx, x + (advanceWidth * scale), -10000, x + (advanceWidth * scale), 10000); }; +/** + * Convert the Glyph's Path to a string of path data instructions + * @param {object|number} [options={decimalPlaces:2, optimize:true}] - Options object (or amount of decimal places for floating-point values for backwards compatibility) + * @return {string} + * @see Path.toPathData + */ +Glyph.prototype.toPathData = function(options) { + return this.path.toPathData(options); +}; + +/** + * Sets the path data from an SVG path element or path notation + * @param {string|SVGPathElement} + * @param {object} + */ +Glyph.prototype.fromSVG = function(pathData, options = {}) { + return this.path.fromSVG(pathData, options); +}; + +/** + * Convert the Glyph's Path to an SVG element, as a string. + * @param {object|number} [options={decimalPlaces:2, optimize:true}] - Options object (or amount of decimal places for floating-point values for backwards compatibility) + * @return {string} + */ +Glyph.prototype.toSVG = function(options) { + return this.path.toSVG(options, this.toPathData.apply(this, [options])); +}; + +/** + * Convert the path to a DOM element. + * @param {object|number} [options={decimalPlaces:2, optimize:true}] - Options object (or amount of decimal places for floating-point values for backwards compatibility) + * @return {SVGPathElement} + */ +Glyph.prototype.toDOMElement = function(options) { + return this.path.toDOMElement(options); +}; + export default Glyph; diff --git a/src/path.js b/src/path.js index 19d40763..60d6ade0 100644 --- a/src/path.js +++ b/src/path.js @@ -16,6 +16,290 @@ function Path() { this.strokeWidth = 1; } +function roundDecimal(float, places) { + return +(Math.round(float + 'e+' + places) + 'e-' + places); +} + +function optimizeCommands(commands) { + // separate subpaths + let subpaths = [[]]; + for (let i = 0; i < commands.length; i += 1) { + const subpath = subpaths[subpaths.length - 1]; + const cmd = commands[i]; + const firstCommand = subpath[0]; + const secondCommand = subpath[1]; + const previousCommand = subpath[subpath.length - 1]; + subpath.push(cmd); + if (cmd.type === 'Z') { + // When closing at the same position as the path started, + // remove unnecessary line command + if ( + firstCommand.type === 'M' && + secondCommand.type === 'L' && + previousCommand.type === 'L' && + previousCommand.x === firstCommand.x && + previousCommand.y === firstCommand.y + ) { + subpath.shift(); + subpath[0].type = 'M'; + } + + if (i + 1 < commands.length) { + subpaths.push([]); + } + } else if (cmd.type === 'L') { + // remove lines that lead to the same position as the previous command + if (previousCommand.x === cmd.x && previousCommand.y === cmd.y) { + subpath.pop(); + } + } + } + commands = [].concat.apply([], subpaths); // flatten again + return commands; +} + +/** + * Returns options merged with the default options for parsing SVG data + * @param {object} options (optional) + */ +function createSVGParsingOptions(options) { + const defaultOptions = { + decimalPlaces: 2, + optimize: true, + flipY: true, + flipYBase: undefined, + scale: 1, + x: 0, + y: 0 + }; + const newOptions = Object.assign({}, defaultOptions, options); + return newOptions; +} + +/** + * Returns options merged with the default options for outputting SVG data + * @param {object} options (optional) + */ +function createSVGOutputOptions(options) { + // accept number for backwards compatibility + // and in that case set flipY to false + if (parseInt(options) === options) { + options = { decimalPlaces: options, flipY: false }; + } + const defaultOptions = { + decimalPlaces: 2, + optimize: true, + flipY: true, + flipYBase: undefined + }; + const newOptions = Object.assign({}, defaultOptions, options); + return newOptions; +} + +/** + * Sets the path data from an SVG path element or path notation + * @param {string|SVGPathElement} + * @param {object} + */ +Path.prototype.fromSVG = function(pathData, options = {}) { + if (typeof SVGPathElement !== 'undefined' && pathData instanceof SVGPathElement) { + pathData = pathData.getAttribute('d'); + } + + // set/merge default options + options = createSVGParsingOptions(options); + + this.commands = []; + + // TODO: a generator function could possibly increase performance and reduce memory usage, + // but our current build process doesn't allow to use those yet. + const number = '0123456789'; + const supportedCommands = 'MmLlQqCcZzHhVv'; + const unsupportedCommands = 'SsTtAa'; + const sign = '-+'; + + let command = {}; + let buffer = ['']; + + let isUnexpected = false; + + function parseBuffer(buffer) { + return buffer.filter(b => b.length).map(b => { + let float = parseFloat(b); + if (options.decimalPlaces || options.decimalPlaces === 0) { + float = roundDecimal(float, options.decimalPlaces); + } + return float; + }); + } + + function makeRelative(buffer) { + if (!this.commands.length) { + return buffer; + } + const lastCommand = this.commands[this.commands.length - 1]; + for (let i = 0; i < buffer.length; i++) { + buffer[i] += lastCommand[i & 1 ? 'y' : 'x']; + } + return buffer; + } + + function applyCommand() { + // ignore empty commands + if (command.type === undefined) { + return; + } + const commandType = command.type.toUpperCase(); + const relative = commandType !== 'Z' && command.type.toUpperCase() !== command.type; + let parsedBuffer = parseBuffer(buffer); + buffer = ['']; + if (!parsedBuffer.length && commandType !== 'Z') { + return; + } + if (relative && commandType !== 'H' && commandType !== 'V') { + parsedBuffer = makeRelative.apply(this, [parsedBuffer]); + } + + const currentX = this.commands.length ? this.commands[this.commands.length - 1].x || 0 : 0; + const currentY = this.commands.length ? this.commands[this.commands.length - 1].y || 0 : 0; + + switch (commandType) { + case 'M': + this.moveTo(...parsedBuffer); + break; + case 'L': + this.lineTo(...parsedBuffer); + break; + case 'V': + // multiple values interpreted as consecutive commands + for (let i = 0; i < parsedBuffer.length; i++) { + let offset = 0; + if (relative) { + offset = this.commands.length ? (this.commands[this.commands.length - 1].y || 0) : 0; + } + this.lineTo(currentX, parsedBuffer[i] + offset); + } + break; + case 'H': + // multiple values interpreted as consecutive commands + for (let i = 0; i < parsedBuffer.length; i++) { + let offset = 0; + if (relative) { + offset = this.commands.length ? (this.commands[this.commands.length - 1].x || 0) : 0; + } + this.lineTo(parsedBuffer[i] + offset, currentY); + } + break; + case 'C': + this.bezierCurveTo(...parsedBuffer); + break; + case 'Q': + this.quadraticCurveTo(...parsedBuffer); + break; + case 'Z': + if (this.commands.length < 1 || this.commands[this.commands.length - 1].type !== 'Z') { + this.close(); + } + break; + } + + if (this.commands.length) { + for (const prop in this.commands[this.commands.length - 1]) { + if (this.commands[this.commands.length - 1][prop] === undefined) { + this.commands[this.commands.length - 1][prop] = 0; + } + } + } + } + + for (let i = 0; i < pathData.length; i++) { + const token = pathData.charAt(i); + const lastBuffer = buffer[buffer.length - 1]; + if (number.indexOf(token) > -1) { + buffer[buffer.length - 1] += token; + } else if (sign.indexOf(token) > -1) { + if (!command.type && !this.commands.length) { + command.type = 'L'; + } + + if (token === '-') { + if (!command.type || lastBuffer.indexOf('-') > 0) { + isUnexpected = true; + } else if (lastBuffer.length) { + buffer.push('-'); + } else { + buffer[buffer.length - 1] = token; + } + } else { + if (!command.type || lastBuffer.length > 0) { + isUnexpected = true; + } else { + continue; + } + } + } else if (supportedCommands.indexOf(token) > -1) { + if (command.type) { + applyCommand.apply(this); + command = { type: token }; + } else { + command.type = token; + } + } else if (unsupportedCommands.indexOf(token) > -1) { + // TODO: try to interpolate commands not directly supported? + throw new Error('Unsupported path command: ' + token + '. Currently supported commands are ' + supportedCommands.split('').join(', ') + '.'); + } else if (' ,\t\n\r\f\v'.indexOf(token) > -1) { + buffer.push(''); + } else if (token === '.') { + if (!command.type || lastBuffer.indexOf(token) > -1) { + isUnexpected = true; + } else { + buffer[buffer.length - 1] += token; + } + } else { + isUnexpected = true; + } + + if (isUnexpected) { + throw new Error('Unexpected character: ' + token + ' at offset ' + i); + } + } + applyCommand.apply(this); + + if (options.optimize) { + this.commands = optimizeCommands(this.commands); + } + + const flipY = options.flipY; + let flipYBase = options.flipYBase; + if (flipY === true && options.flipYBase === undefined) { + const boundingBox = this.getBoundingBox(); + flipYBase = boundingBox.y1 + boundingBox.y2; + } + // apply x/y offset, flipping and scaling + for (const i in this.commands) { + const cmd = this.commands[i]; + for (const prop in cmd) { + if (['x', 'x1', 'x2'].includes(prop)) { + this.commands[i][prop] = options.x + cmd[prop] * options.scale; + } else if (['y', 'y1', 'y2'].includes(prop)) { + this.commands[i][prop] = options.y + (flipY ? flipYBase - cmd[prop] : cmd[prop]) * options.scale; + } + } + } + + return this; +}; + +/** + * Generates a new Path() from an SVG path element or path notation + * @param {string|SVGPathElement} + * @param {object} + */ +Path.fromSVG = function(path, options) { + const newPath = new Path(); + return newPath.fromSVG(path, options); +}; + /** * @param {number} x * @param {number} y @@ -230,17 +514,18 @@ Path.prototype.draw = function(ctx) { /** * Convert the Path to a string of path data instructions * See http://www.w3.org/TR/SVG/paths.html#PathData - * @param {number} [decimalPlaces=2] - The amount of decimal places for floating-point values + * @param {object|number} [options={decimalPlaces:2, optimize:true}] - Options object (or amount of decimal places for floating-point values for backwards compatibility) * @return {string} */ -Path.prototype.toPathData = function(decimalPlaces) { - decimalPlaces = decimalPlaces !== undefined ? decimalPlaces : 2; +Path.prototype.toPathData = function(options) { + // set/merge default options + options = createSVGOutputOptions(options); function floatToString(v) { - if (Math.round(v) === v) { - return '' + Math.round(v); + if (Math.round(v) === roundDecimal(v, options.decimalPlaces)) { + return '' + roundDecimal(v, options.decimalPlaces); } else { - return v.toFixed(decimalPlaces); + return roundDecimal(v, options.decimalPlaces).toFixed(options.decimalPlaces); } } @@ -258,17 +543,50 @@ Path.prototype.toPathData = function(decimalPlaces) { return s; } + let commandsCopy = this.commands; + if (options.optimize) { + // apply path optimizations + commandsCopy = JSON.parse(JSON.stringify(this.commands)); // make a deep clone + commandsCopy = optimizeCommands(commandsCopy); + } + + const flipY = options.flipY; + let flipYBase = options.flipYBase; + if (flipY === true && flipYBase === undefined) { + const tempPath = new Path(); + tempPath.extend(commandsCopy); + const boundingBox = tempPath.getBoundingBox(); + flipYBase = boundingBox.y1 + boundingBox.y2; + } let d = ''; - for (let i = 0; i < this.commands.length; i += 1) { - const cmd = this.commands[i]; + for (let i = 0; i < commandsCopy.length; i += 1) { + const cmd = commandsCopy[i]; if (cmd.type === 'M') { - d += 'M' + packValues(cmd.x, cmd.y); + d += 'M' + packValues( + cmd.x, + flipY ? flipYBase - cmd.y : cmd.y + ); } else if (cmd.type === 'L') { - d += 'L' + packValues(cmd.x, cmd.y); + d += 'L' + packValues( + cmd.x, + flipY ? flipYBase - cmd.y : cmd.y + ); } else if (cmd.type === 'C') { - d += 'C' + packValues(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y); + d += 'C' + packValues( + cmd.x1, + flipY ? flipYBase - cmd.y1 : cmd.y1, + cmd.x2, + flipY ? flipYBase - cmd.y2 : cmd.y2, + cmd.x, + flipY ? flipYBase - cmd.y : cmd.y + ); } else if (cmd.type === 'Q') { - d += 'Q' + packValues(cmd.x1, cmd.y1, cmd.x, cmd.y); + d += 'Q' + packValues( + cmd.x1, + flipY ? flipYBase - cmd.y1 : cmd.y1, + cmd.x, + flipY ? flipYBase - cmd.y : cmd.y + ); } else if (cmd.type === 'Z') { d += 'Z'; } @@ -279,12 +597,16 @@ Path.prototype.toPathData = function(decimalPlaces) { /** * Convert the path to an SVG element, as a string. - * @param {number} [decimalPlaces=2] - The amount of decimal places for floating-point values + * @param {object|number} [options={decimalPlaces:2, optimize:true}] - Options object (or amount of decimal places for floating-point values for backwards compatibility) + * @param {string} - will be calculated automatically, but can be provided from Glyph's wrapper function * @return {string} */ -Path.prototype.toSVG = function(decimalPlaces) { +Path.prototype.toSVG = function(options, pathData) { + if (!pathData) { + pathData = this.toPathData(options); + } let svg = '' + ); + // we can't test toDOMElement() in node context! + + const trianglePathUp = 'M318 230L182 230L250 93Z'; + const trianglePathDown = 'M318 320L182 320L250 457Z'; + const flipOption = { + minY: font.ascender, + maxY: font.ascender, + flipY: true, + flipYBase: font.ascender + font.descender + }; + glyph.fromSVG(trianglePathUp, flipOption); + assert.equal(glyph.path.toPathData({flipY: false}), trianglePathDown); + assert.equal(glyph.toPathData(flipOption), trianglePathUp); + }); + }); }); describe('glyph.js on low memory mode', function() { diff --git a/test/path.js b/test/path.js new file mode 100644 index 00000000..59c3a1b8 --- /dev/null +++ b/test/path.js @@ -0,0 +1,100 @@ +import assert from 'assert'; +import { Path } from '../src/opentype'; + +describe('path.js', function() { + const testPath1 = new Path(); + testPath1.moveTo(1, 2); + testPath1.lineTo(3, 4); + testPath1.curveTo(5, 6, 7, 8, 9, 10); + testPath1.quadTo(11, 12, 13, 14, 15, 16); + testPath1.close(); + + const testPath2 = new Path(); // two squares + testPath2.moveTo(0, 50); + testPath2.lineTo(0, 250); + testPath2.lineTo(50, 250); + testPath2.lineTo(100, 250); + testPath2.lineTo(150, 250); + testPath2.lineTo(200, 250); + testPath2.lineTo(200, 50); + testPath2.lineTo(0, 50); + testPath2.close(); + testPath2.moveTo(250, 50); + testPath2.lineTo(250, 250); + testPath2.lineTo(300, 250); + testPath2.lineTo(350, 250); + testPath2.lineTo(400, 250); + testPath2.lineTo(450, 250); + testPath2.lineTo(450, 50); + testPath2.lineTo(250, 50); + testPath2.close(); + + it('should set path commands correctly', function() { + const expectedCommands = [ + { type: 'M', x: 1, y: 2 }, + { type: 'L', x: 3, y: 4 }, + { type: 'C', x1: 5, y1: 6, x2: 7, y2: 8, x: 9, y: 10 }, + { type: 'Q', x1: 11, y1: 12, x: 13, y: 14 }, + { type: 'Z' } + ]; + const svg = 'M1 2L3 4C5 6 7 8 9 10Q11 12 13 14Z'; + assert.deepEqual(testPath1.commands, expectedCommands); + assert.deepEqual(Path.fromSVG(svg, {flipY: false}).commands, expectedCommands); + }); + + it('should return a streamlined SVG path (no commas, no additional spaces, only absolute commands)', function() { + const input = 'M1,2 L 3 4Z M .5 6.7 L 8 9 l 2,1 m1 1 c 1 2,3 4 5, 6q-7.8-9.0 -1.011 12 m-13.99-28 h 13 15 V 17 19 v21 23 25 H27 V28 zzzZZzzz'; + const expectedSVG = 'M1 2L3 4ZM0.50 6.70L8 9L10 10M11 11C12 13 14 15 16 17Q8.20 8 14.99 29M1 1L14 1L29 1L29 17L29 19L29 40L29 63L29 88L27 88L27 28Z'; + const path = Path.fromSVG(input, {flipY: false}); + assert.deepEqual(path.toPathData({flipY: false}), expectedSVG); + }); + + it('should accept integer or correct fallback for decimalPlaces backwards compatibility', function() { + const expectedResult = 'M0.58 0.75L1.76-1.25'; + const expectedResult2 = 'M0.575 0.750L1.757-1.254'; + const path = new Path(); + path.moveTo(0.575, 0.75); + path.lineTo(1.7567, -1.2543); + assert.equal(path.toPathData({flipY: false}), expectedResult); + assert.equal(path.toPathData({optimize: true, flipY: false}), expectedResult); + assert.equal(path.toPathData(3), expectedResult2); + }); + + it('should not optimize SVG paths if parameter is set falsy', function() { + const unoptimizedResult = 'M0 50L0 250L50 250L100 250L150 250L200 250L200 50L0 50ZM250 50L250 250L300 250L350 250L400 250L450 250L450 50L250 50Z'; + assert.equal(testPath2.toPathData({optimize: false, flipY: false}), unoptimizedResult); + }); + + it('should optimize SVG paths if path closing point matches starting point', function() { + const optimizedResult = 'M0 250L50 250L100 250L150 250L200 250L200 50L0 50ZM250 250L300 250L350 250L400 250L450 250L450 50L250 50Z'; + assert.equal(testPath2.toPathData({flipY: false}), optimizedResult); + assert.equal(testPath2.toPathData({optimize: true, flipY: false}), optimizedResult); + }); + + it('should optimize SVG paths if they include unnecessary lineTo commands', function() { + const path = (new Path()).fromSVG( + 'M199 97 L 199 97 L 313 97 L 313 97 Q 396 97 444 61 L 444 61 L 444 61 Q 493 25 493 -36 L 493 -36 L 493 -36' + + 'Q 493 -108 428 -151 L 428 -151 L 428 -151 Q 363 -195 255 -195 L 255 -195 L 255 -195 Q 150 -195 90 -156 Z' + ); + const expectedResult = ''; + assert.equal(path.toSVG({optimize: true}), expectedResult); + // we can't test toDOMElement() in node context! + }); + + it('should calculate flipY from bounding box if set to true', function() { + const jNormal = 'M25 772C130 725 185 680 185 528L185 33L93 33L93 534C93 647 60 673-9 705ZM204-150' + + 'C204-185 177-212 139-212C101-212 75-185 75-150C75-114 101-87 139-87C177-87 204-114 204-150Z'; + const jUpsideDown = 'M25-212C130-165 185-120 185 32L185 527L93 527L93 26C93-87 60-113-9-145ZM204 710' + + 'C204 745 177 772 139 772C101 772 75 745 75 710C75 674 101 647 139 647C177 647 204 674 204 710Z'; + const path = Path.fromSVG(jNormal); + assert.equal(path.toPathData({flipY: false}), jUpsideDown); + assert.equal(path.toPathData(), jNormal); + }); + + it('should handle scaling and offset', function() { + const inputPath = 'M0 1L2 0L3 0L5 1L5 5L0 5Z'; + const expectedPath = 'M1 4.50L6 2L8.50 2L13.50 4.50L13.50 14.50L1 14.50Z'; + const path = Path.fromSVG(inputPath, { x: 1, y: 2, scale: 2.5 }); + assert.equal(path.toPathData(), expectedPath); + }); +});