|
| 1 | +const defined = require('defined'); |
| 2 | +const convertUnits = require('convert-units'); |
| 3 | +var convert = require('convert-length'); |
| 4 | + |
| 5 | +// 96 DPI for SVG programs like Inkscape etc |
| 6 | +const TO_PX = 35.43307; |
| 7 | +var DEFAULT_PIXELS_PER_INCH = 90; |
| 8 | +var DEFAULT_PEN_THICKNESS = 0.03; |
| 9 | +var DEFAULT_PEN_THICKNESS_UNIT = 'cm'; |
| 10 | + |
| 11 | +function cm(value, unit) { |
| 12 | + return convertUnits(value) |
| 13 | + .from(unit) |
| 14 | + .to('cm'); |
| 15 | +} |
| 16 | + |
| 17 | +// create svg paths from polylines [[x,y], ...] |
| 18 | +function polyLinesToSvgPaths(polylines, opt = {}) { |
| 19 | + if (!opt.units || typeof opt.units !== 'string') |
| 20 | + throw new TypeError( |
| 21 | + 'must specify { units } string as well as dimensions, such as: { units: "in" }' |
| 22 | + ); |
| 23 | + const units = opt.units.toLowerCase(); |
| 24 | + const decimalPlaces = 5; |
| 25 | + |
| 26 | + let commands = []; |
| 27 | + polylines.forEach(line => { |
| 28 | + line.forEach((point, j) => { |
| 29 | + const type = j === 0 ? 'M' : 'L'; |
| 30 | + const x = (TO_PX * cm(point[0], units)).toFixed(decimalPlaces); |
| 31 | + const y = (TO_PX * cm(point[1], units)).toFixed(decimalPlaces); |
| 32 | + commands.push(`${type} ${x} ${y}`); |
| 33 | + }); |
| 34 | + }); |
| 35 | + |
| 36 | + return commands; |
| 37 | +} |
| 38 | + |
| 39 | +// create svg paths from Arc objects |
| 40 | +function arcsToSvgPaths(arcs, opt = {}) { |
| 41 | + if (!opt.units || typeof opt.units !== 'string') |
| 42 | + throw new TypeError( |
| 43 | + 'must specify { units } string as well as dimensions, such as: { units: "in" }' |
| 44 | + ); |
| 45 | + const units = opt.units.toLowerCase(); |
| 46 | + if (units === 'px') |
| 47 | + throw new Error( |
| 48 | + 'px units are not yet supported by this function, your print should be defined in "cm" or "in"' |
| 49 | + ); |
| 50 | + |
| 51 | + let commands = []; |
| 52 | + arcs.forEach(input => { |
| 53 | + let arc = input.toSvgPixels(units); |
| 54 | + commands.push( |
| 55 | + `M${arc.startX} ${arc.startY} A${arc.radiusX},${arc.radiusY} ${ |
| 56 | + arc.rotX |
| 57 | + } ${arc.largeArcFlag},${arc.sweepFlag} ${arc.endX},${arc.endY}` |
| 58 | + ); |
| 59 | + }); |
| 60 | + |
| 61 | + return commands; |
| 62 | +} |
| 63 | + |
| 64 | +// convert paths to an svg file |
| 65 | +// mostly formatting into svg-xml |
| 66 | +function pathsToSvgFile(paths, opt = {}) { |
| 67 | + opt = opt || {}; |
| 68 | + |
| 69 | + var width = opt.width; |
| 70 | + var height = opt.height; |
| 71 | + |
| 72 | + var computeBounds = |
| 73 | + typeof width === 'undefined' || typeof height === 'undefined'; |
| 74 | + if (computeBounds) { |
| 75 | + throw new Error('Must specify "width" and "height" options'); |
| 76 | + } |
| 77 | + |
| 78 | + var units = opt.units || 'px'; |
| 79 | + |
| 80 | + var convertOptions = { |
| 81 | + roundPixel: false, |
| 82 | + precision: defined(opt.precision, 5), |
| 83 | + pixelsPerInch: DEFAULT_PIXELS_PER_INCH |
| 84 | + }; |
| 85 | + var svgPath = paths.join(' '); |
| 86 | + var viewWidth = convert(width, units, 'px', convertOptions).toString(); |
| 87 | + var viewHeight = convert(height, units, 'px', convertOptions).toString(); |
| 88 | + var fillStyle = opt.fillStyle || 'none'; |
| 89 | + var strokeStyle = opt.strokeStyle || 'black'; |
| 90 | + var lineWidth = opt.lineWidth; |
| 91 | + |
| 92 | + // Choose a default line width based on a relatively fine-tip pen |
| 93 | + if (typeof lineWidth === 'undefined') { |
| 94 | + // Convert to user units |
| 95 | + lineWidth = convert( |
| 96 | + DEFAULT_PEN_THICKNESS, |
| 97 | + DEFAULT_PEN_THICKNESS_UNIT, |
| 98 | + units, |
| 99 | + convertOptions |
| 100 | + ).toString(); |
| 101 | + } |
| 102 | + |
| 103 | + return [ |
| 104 | + '<?xml version="1.0" standalone="no"?>', |
| 105 | + ' <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ', |
| 106 | + ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">', |
| 107 | + ' <svg width="' + width + units + '" height="' + height + units + '"', |
| 108 | + ' xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 ' + viewWidth + ' ' + viewHeight + '">', |
| 109 | + ' <g>', |
| 110 | + ' <path d="' + svgPath + '" fill="' + fillStyle + '" stroke="' + strokeStyle + '" stroke-width="' + lineWidth + units + '" />', |
| 111 | + ' </g>', |
| 112 | + '</svg>' |
| 113 | + ].join('\n'); |
| 114 | +} |
| 115 | + |
| 116 | +// container class for the data needed to create an svg arc path |
| 117 | +class Arc { |
| 118 | + constructor() { |
| 119 | + this.startX = 0; |
| 120 | + this.startY = 0; |
| 121 | + this.endX = 0; |
| 122 | + this.endY = 0; |
| 123 | + this.radiusX = 0; |
| 124 | + this.radiusY = 0; |
| 125 | + this.rotX = 0; |
| 126 | + this.largeArcFlag = 0; |
| 127 | + this.sweepFlag = 1; |
| 128 | + } |
| 129 | + |
| 130 | + // transform canvas pixels to svg |
| 131 | + toSvgPixels(units, decimalPlaces = 5) { |
| 132 | + let a = new Arc(); |
| 133 | + a.startX = (TO_PX * cm(this.startX, units)).toFixed(decimalPlaces); |
| 134 | + a.startY = (TO_PX * cm(this.startY, units)).toFixed(decimalPlaces); |
| 135 | + a.endX = (TO_PX * cm(this.endX, units)).toFixed(decimalPlaces); |
| 136 | + a.endY = (TO_PX * cm(this.endY, units)).toFixed(decimalPlaces); |
| 137 | + |
| 138 | + a.radiusX = (TO_PX * cm(this.radiusX, units)).toFixed(decimalPlaces); |
| 139 | + a.radiusY = (TO_PX * cm(this.radiusY, units)).toFixed(decimalPlaces); |
| 140 | + a.rotX = this.rotX; |
| 141 | + a.largeArcFlag = this.largeArcFlag; |
| 142 | + a.sweepFlag = this.sweepFlag; |
| 143 | + |
| 144 | + return a; |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +// this class makes it easier to handle svg output |
| 149 | +// use in this order: |
| 150 | +// - new SvgFile() |
| 151 | +// - addLine or addArc or addCircle (repeat x times) |
| 152 | +// - toSvg(options) |
| 153 | +class SvgFile { |
| 154 | + constructor(options = {}) { |
| 155 | + this.lines = []; |
| 156 | + this.arcs = []; |
| 157 | + this.options = options; |
| 158 | + } |
| 159 | + |
| 160 | + addLine(line) { |
| 161 | + this.lines.push(line); |
| 162 | + } |
| 163 | + |
| 164 | + addCircle(cx, cy, radius) { |
| 165 | + this.arcs.push(...createCircle(cx, cy, radius)); |
| 166 | + } |
| 167 | + |
| 168 | + addArc(cx, cy, radius, sAngle, eAngle) { |
| 169 | + this.arcs.push(createArc(cx, cy, radius, sAngle, eAngle)); |
| 170 | + } |
| 171 | + |
| 172 | + toSvg(options = null){ |
| 173 | + if (!options) { options = this.options; } |
| 174 | + let lineCommands = polyLinesToSvgPaths(this.lines, options); |
| 175 | + let arcCommands = arcsToSvgPaths(this.arcs, options); |
| 176 | + return pathsToSvgFile([...lineCommands, ...arcCommands], options); |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +// create a circle from 2 180degree arcs |
| 181 | +// (a single 360 degree arc cancels itself out in svg) |
| 182 | +function createCircle(cx, cy, radius) { |
| 183 | + let a1 = new Arc(); |
| 184 | + a1.startX = cx + radius; |
| 185 | + a1.startY = cy; |
| 186 | + a1.endX = cx - radius; |
| 187 | + a1.endY = cy; |
| 188 | + a1.radiusX = radius; |
| 189 | + a1.radiusY = radius; |
| 190 | + |
| 191 | + let a2 = new Arc(); |
| 192 | + a2.startX = cx - radius; |
| 193 | + a2.startY = cy; |
| 194 | + a2.endX = cx + radius; |
| 195 | + a2.endY = cy; |
| 196 | + a2.radiusX = radius; |
| 197 | + a2.radiusY = radius; |
| 198 | + |
| 199 | + return [a1, a2]; |
| 200 | +} |
| 201 | + |
| 202 | +// create an Arc object |
| 203 | +function createArc(cx, cy, radius, sAngle, eAngle) { |
| 204 | + let zeroX = cx + radius, |
| 205 | + zeroY = cy, |
| 206 | + start = rotate([zeroX, zeroY], [cx, cy], sAngle), |
| 207 | + end = rotate([zeroX, zeroY], [cx, cy], eAngle); |
| 208 | + |
| 209 | + // zero = 3 o'clock like in canvas2D |
| 210 | + // to calculate the x,y for the start of the arc, we rotate [zero] around [cx,cy] for [sAngle] degrees |
| 211 | + // same for the end of the arc, but [eAngle] degrees |
| 212 | + |
| 213 | + let a1 = new Arc(); |
| 214 | + a1.radiusX = radius; |
| 215 | + a1.radiusY = radius; |
| 216 | + a1.startX = start[0]; |
| 217 | + a1.startY = start[1]; |
| 218 | + a1.endX = end[0]; |
| 219 | + a1.endY = end[1]; |
| 220 | + // if the arc spans >= 180 degrees, use the large-arc-flag |
| 221 | + a1.largeArcFlag = (eAngle - sAngle) >= 180 ? 1 : 0; |
| 222 | + |
| 223 | + return a1; |
| 224 | +} |
| 225 | + |
| 226 | +// rotate a [point] over [angle] degrees around [center] |
| 227 | +const rotate = (point, center, angle) => { |
| 228 | + if (angle === 0) return point; |
| 229 | + |
| 230 | + let radians = (Math.PI / 180) * angle, |
| 231 | + x = point[0], |
| 232 | + y = point[1], |
| 233 | + cx = center[0], |
| 234 | + cy = center[1], |
| 235 | + cos = Math.cos(radians), |
| 236 | + sin = Math.sin(radians), |
| 237 | + nx = cos * (x - cx) - sin * (y - cy) + cx, |
| 238 | + ny = cos * (y - cy) + sin * (x - cx) + cy; |
| 239 | + return [nx, ny]; |
| 240 | +}; |
| 241 | + |
| 242 | +module.exports.arcsToSvgPaths = arcsToSvgPaths; |
| 243 | +module.exports.polyLinesToSvgPaths = polyLinesToSvgPaths; |
| 244 | +module.exports.pathsToSvgFile = pathsToSvgFile; |
| 245 | +module.exports.Arc = Arc; |
| 246 | +module.exports.SvgFile = SvgFile; |
| 247 | +module.exports.createCircle = createCircle; |
| 248 | +module.exports.createArc = createArc; |
0 commit comments