├── src ├── index.ts └── SVG.ts ├── .gitignore ├── tsconfig.json ├── types └── d-path-parser.d.ts ├── .github └── workflows │ └── nodejs.yml ├── LICENSE ├── README.md ├── package.json └── examples └── index.html /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SVG'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.log 4 | dist 5 | lib 6 | docs 7 | example.api.json* 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@pixi/extension-scripts/lib/configs/tsconfig.json", 3 | "include": [ 4 | "./src/**/*.ts", 5 | "./types/*.d.ts" 6 | ] 7 | } -------------------------------------------------------------------------------- /types/d-path-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'd-path-parser' { 2 | interface Point { 3 | x: number; 4 | y: number; 5 | } 6 | interface Command { 7 | code: string; 8 | value: number; 9 | end: Point; 10 | cp1: Point; 11 | cp2: Point; 12 | cp: Point; 13 | rotation: number; 14 | radii: Point; 15 | clockwise: boolean; 16 | large: boolean; 17 | } 18 | export default function parse(path: string): Command[]; 19 | } -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: 3 | push: 4 | branches: [ '**' ] 5 | tags: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 16.x 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | cache: 'npm' 18 | - name: Install npm 19 | run: npm install -g npm@8 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Built and Test 23 | run: npm test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The pixi-svg License 2 | 3 | Copyright (c) 2017 Matt Karl, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PixiJS SVG Graphics 2 | 3 | SVG to Graphics DisplayObject for PixiJS. 4 | 5 | [![Node.js CI](https://github.com/bigtimebuddy/pixi-svg/workflows/Node.js%20CI/badge.svg)](https://github.com/bigtimebuddy/pixi-svg/actions?query=workflow%3A%22Node.js+CI%22) 6 | 7 | ## Examples 8 | 9 | See SVG and pixi.js side-by-side comparisons: 10 | https://mattkarl.com/pixi-svg/examples/ 11 | 12 | ## Install 13 | 14 | ```bash 15 | npm install pixi-svg --save 16 | # or 17 | yarn add pixi-svg 18 | ``` 19 | 20 | ## Usage 21 | 22 | For an inline SVG element: 23 | 24 | ```html 25 | 26 | 27 | 28 | ``` 29 | 30 | Create a new `PIXI.SVG` object, provide the `` element. 31 | 32 | ```js 33 | import { Application } from 'pixi.js'; 34 | import { SVG } from 'pixi-svg'; 35 | 36 | const svg = new SVG(document.getElementById("svg1")); 37 | const app = new Application(); 38 | app.stage.addChild(svg); 39 | ``` 40 | 41 | ## Supported Features 42 | 43 | Only supports a subset of SVG's features. Currently, this includes: 44 | - SVG Elements: 45 | - `` 46 | - `` 47 | - `` 48 | - `` 49 | - `` 50 | - `` 51 | - `style` attributes with the following properties: 52 | - `stroke` 53 | - `stroke-width` 54 | - `fill` 55 | - `opacity` 56 | - `stroke-linejoin` 57 | - `stroke-linecap` 58 | 59 | ## Unsupported Features 60 | 61 | - Basically, anything not listed above 62 | - Interactivity 63 | - Any `transform` attributes 64 | - ` 4 | 5 | 6 | pixi-svg 7 | 8 | 46 | 47 | 48 | 49 | 50 | 182 |
183 |
PixiJS
184 |
Native SVG
185 |
186 | 237 | 238 | -------------------------------------------------------------------------------- /src/SVG.ts: -------------------------------------------------------------------------------- 1 | import { Graphics } from '@pixi/graphics'; 2 | import dPathParser from 'd-path-parser'; 3 | import color from 'tinycolor2'; 4 | import arcToBezier from 'svg-arc-to-cubic-bezier'; 5 | 6 | interface SVGStyle 7 | { 8 | fill: string | null; 9 | opacity: string | null; 10 | stroke: string | null; 11 | strokeWidth: string | null; 12 | strokeOpacity: string | null; 13 | cap: string | null; 14 | join: string | null; 15 | miterLimit: string | null; 16 | } 17 | 18 | /** 19 | * Scalable Graphics drawn from SVG image document. 20 | * @class SVG 21 | * @extends PIXI.Graphics 22 | */ 23 | class SVG extends Graphics 24 | { 25 | /** Fallback line color */ 26 | private lineColor: string | null = null; 27 | 28 | /** 29 | * @param svg - Inline SVGElement `` or buffer. 30 | */ 31 | constructor(svg?: SVGSVGElement | SVGElement | string) 32 | { 33 | super(); 34 | 35 | if (svg) 36 | { 37 | this.drawSVG(svg); 38 | } 39 | } 40 | 41 | /** 42 | * Draw an SVG element. 43 | * @param svg - Inline SVGElement `` or buffer. 44 | * @return Element suitable for chaining. 45 | */ 46 | drawSVG(svg: SVGSVGElement | SVGElement | string): this 47 | { 48 | if (typeof svg === 'string') 49 | { 50 | const div = document.createElement('div'); 51 | 52 | div.innerHTML = svg.trim(); 53 | svg = div.querySelector('svg') as SVGElement; 54 | } 55 | 56 | if (!svg) 57 | { 58 | throw new Error('Missing element in SVG constructor'); 59 | } 60 | 61 | this._svgFill(svg); 62 | this._svgChildren(svg.children); 63 | 64 | return this; 65 | } 66 | 67 | /** 68 | * Create a PIXI Graphic from SVG element 69 | * @param children - Collection of SVG nodes 70 | * @param inherit - Whether to inherit fill settings. 71 | */ 72 | private _svgChildren(children: HTMLCollection, inherit = false): void 73 | { 74 | for (let i = 0; i < children.length; i++) 75 | { 76 | const child = children[i] as unknown as SVGElement; 77 | 78 | this._svgFill(child, inherit); 79 | switch (child.nodeName.toLowerCase()) 80 | { 81 | case 'path': { 82 | this._svgPath(child as SVGPathElement); 83 | break; 84 | } 85 | case 'circle': 86 | case 'ellipse': { 87 | this._svgCircle(child as SVGCircleElement); 88 | break; 89 | } 90 | case 'rect': { 91 | this._svgRect(child as SVGRectElement); 92 | break; 93 | } 94 | case 'polygon': { 95 | this._svgPoly(child as SVGPolygonElement, true); 96 | break; 97 | } 98 | case 'polyline': { 99 | this._svgPoly(child as SVGPolylineElement); 100 | break; 101 | } 102 | case 'g': { 103 | break; 104 | } 105 | default: { 106 | // eslint-disable-next-line no-console 107 | console.info(`[PIXI.SVG] <${child.nodeName}> elements unsupported`); 108 | break; 109 | } 110 | } 111 | this._svgChildren(child.children, true); 112 | } 113 | } 114 | 115 | /** Convert the Hexidecimal string (e.g., "#fff") to uint */ 116 | private _hexToUint(hex: string): number 117 | { 118 | if (hex[0] === '#') 119 | { 120 | // Remove the hash 121 | hex = hex.substr(1); 122 | 123 | // Convert shortcolors fc9 to ffcc99 124 | if (hex.length === 3) 125 | { 126 | hex = hex.replace(/([a-f0-9])/ig, '$1$1'); 127 | } 128 | 129 | return parseInt(hex, 16); 130 | } 131 | 132 | const { r, g, b } = color(hex).toRgb(); 133 | 134 | return (r << 16) + (g << 8) + b; 135 | } 136 | 137 | /** 138 | * Render a element or element 139 | * @param node - Circle element 140 | */ 141 | private _svgCircle(node: SVGCircleElement): void 142 | { 143 | let heightProp = 'r'; 144 | let widthProp = 'r'; 145 | const isEllipse = node.nodeName === 'elipse'; 146 | 147 | if (isEllipse) 148 | { 149 | heightProp += 'x'; 150 | widthProp += 'y'; 151 | } 152 | const width = parseFloat(node.getAttribute(widthProp) as string); 153 | const height = parseFloat(node.getAttribute(heightProp) as string); 154 | const cx = node.getAttribute('cx'); 155 | const cy = node.getAttribute('cy'); 156 | let x = 0; 157 | let y = 0; 158 | 159 | if (cx !== null) 160 | { 161 | x = parseFloat(cx); 162 | } 163 | if (cy !== null) 164 | { 165 | y = parseFloat(cy); 166 | } 167 | if (!isEllipse) 168 | { 169 | this.drawCircle(x, y, width); 170 | } 171 | else 172 | { 173 | this.drawEllipse(x, y, width, height); 174 | } 175 | } 176 | 177 | /** 178 | * Render a element 179 | * @param node - Rectangle element 180 | */ 181 | private _svgRect(node: SVGRectElement): void 182 | { 183 | const x = parseFloat(node.getAttribute('x') as string); 184 | const y = parseFloat(node.getAttribute('y') as string); 185 | const width = parseFloat(node.getAttribute('width') as string); 186 | const height = parseFloat(node.getAttribute('height') as string); 187 | const rx = parseFloat(node.getAttribute('rx') as string); 188 | 189 | if (rx) 190 | { 191 | this.drawRoundedRect(x, y, width, height, rx); 192 | } 193 | else 194 | { 195 | this.drawRect(x, y, width, height); 196 | } 197 | } 198 | 199 | /** 200 | * Convert the SVG style name into usable name. 201 | * @param name - Name of style 202 | * @return Name used to reference style 203 | */ 204 | private _convertStyleName(name: string): string 205 | { 206 | return name 207 | .trim() 208 | .replace('-width', 'Width') 209 | .replace(/.*-(line)?/, ''); 210 | } 211 | 212 | /** 213 | * Get the style property and parse options. 214 | * @param node - Element with style 215 | * @return Style attributes 216 | */ 217 | private _svgStyle(node: SVGElement): SVGStyle 218 | { 219 | const style = node.getAttribute('style'); 220 | const baseOpacity = node.getAttribute('opacity'); 221 | const result: SVGStyle = { 222 | fill: node.getAttribute('fill'), 223 | opacity: baseOpacity || node.getAttribute('fill-opacity'), 224 | stroke: node.getAttribute('stroke'), 225 | strokeOpacity: baseOpacity || node.getAttribute('stroke-opacity'), 226 | strokeWidth: node.getAttribute('stroke-width'), 227 | cap: node.getAttribute('stroke-linecap'), 228 | join: node.getAttribute('stroke-linejoin'), 229 | miterLimit: node.getAttribute('stroke-miterlimit'), 230 | }; 231 | 232 | if (style !== null) 233 | { 234 | style.split(';').forEach((prop) => 235 | { 236 | const [name, value] = prop.split(':'); 237 | 238 | if (name) 239 | { 240 | const convertedName = this._convertStyleName(name) as keyof typeof result; 241 | 242 | if (!result[convertedName]) 243 | { 244 | result[convertedName] = value.trim(); 245 | } 246 | } 247 | }); 248 | } 249 | 250 | return result; 251 | } 252 | 253 | /** 254 | * Render a polyline element. 255 | * @param node - Polyline element 256 | * @param close - Close the path 257 | */ 258 | private _svgPoly(node: SVGPolylineElement, close?: boolean) 259 | { 260 | const points = (node.getAttribute('points') as string) 261 | .split(/[ ,]/g) 262 | .map((p) => parseInt(p, 10)); 263 | 264 | this.drawPolygon(points); 265 | 266 | if (close) 267 | { 268 | this.closePath(); 269 | } 270 | } 271 | 272 | /** 273 | * Set the fill and stroke style. 274 | * @param node - SVG element 275 | * @param inherit - Inherit the fill style 276 | */ 277 | private _svgFill(node: SVGElement, inherit?: boolean) 278 | { 279 | const { fill, opacity, stroke, strokeOpacity, strokeWidth, cap, join, miterLimit } = this._svgStyle(node); 280 | const defaultLineWidth = stroke !== null ? 1 : 0; 281 | const lineWidth = strokeWidth !== null ? parseFloat(strokeWidth) : defaultLineWidth; 282 | const lineColor = stroke !== null ? this._hexToUint(stroke) : this.lineColor; 283 | 284 | if (fill) 285 | { 286 | if (fill === 'none') 287 | { 288 | this.beginFill(0, 0); 289 | } 290 | else 291 | { 292 | this.beginFill( 293 | this._hexToUint(fill), 294 | opacity !== null ? parseFloat(opacity) : 1, 295 | ); 296 | } 297 | } 298 | else if (!inherit) 299 | { 300 | this.beginFill(0); 301 | } 302 | 303 | this.lineStyle({ 304 | width: stroke === null && strokeWidth === null && inherit ? this.line.width : lineWidth, 305 | alpha: strokeOpacity === null ? this.line.alpha : parseFloat(strokeOpacity), 306 | color: stroke === null && inherit ? this.line.color : lineColor, 307 | cap: cap === null && inherit ? this.line.cap : cap, 308 | join: join === null && inherit ? this.line.join : join, 309 | miterLimit: miterLimit === null && inherit ? this.line.miterLimit : parseFloat(miterLimit as string), 310 | } as any); 311 | 312 | if (node.getAttribute('fill-rule')) 313 | { 314 | // eslint-disable-next-line no-console 315 | console.info('[PIXI.SVG] "fill-rule" attribute is not supported'); 316 | } 317 | } 318 | 319 | /** 320 | * Render a d element 321 | * @param node - Path element. 322 | */ 323 | private _svgPath(node: SVGPathElement) 324 | { 325 | const d = node.getAttribute('d') as string; 326 | let x = 0; 327 | let y = 0; 328 | const commands = dPathParser(d.trim()); 329 | 330 | for (let i = 0; i < commands.length; i++) 331 | { 332 | const command = commands[i]; 333 | 334 | switch (command.code) 335 | { 336 | case 'm': { 337 | this.moveTo( 338 | x += command.end.x, 339 | y += command.end.y, 340 | ); 341 | break; 342 | } 343 | case 'M': { 344 | this.moveTo( 345 | x = command.end.x, 346 | y = command.end.y, 347 | ); 348 | break; 349 | } 350 | case 'H': { 351 | this.lineTo(x = command.value, y); 352 | break; 353 | } 354 | case 'h': { 355 | this.lineTo(x += command.value, y); 356 | break; 357 | } 358 | case 'V': { 359 | this.lineTo(x, y = command.value); 360 | break; 361 | } 362 | case 'v': { 363 | this.lineTo(x, y += command.value); 364 | break; 365 | } 366 | case 'Z': { 367 | this.closePath(); 368 | break; 369 | } 370 | case 'L': { 371 | this.lineTo( 372 | x = command.end.x, 373 | y = command.end.y, 374 | ); 375 | break; 376 | } 377 | case 'l': { 378 | this.lineTo( 379 | x += command.end.x, 380 | y += command.end.y, 381 | ); 382 | break; 383 | } 384 | case 'C': { 385 | this.bezierCurveTo( 386 | command.cp1.x, 387 | command.cp1.y, 388 | command.cp2.x, 389 | command.cp2.y, 390 | x = command.end.x, 391 | y = command.end.y, 392 | ); 393 | break; 394 | } 395 | case 'c': { 396 | const currX = x; 397 | const currY = y; 398 | 399 | this.bezierCurveTo( 400 | currX + command.cp1.x, 401 | currY + command.cp1.y, 402 | currX + command.cp2.x, 403 | currY + command.cp2.y, 404 | x += command.end.x, 405 | y += command.end.y, 406 | ); 407 | break; 408 | } 409 | case 's': 410 | case 'q': { 411 | const currX = x; 412 | const currY = y; 413 | 414 | this.quadraticCurveTo( 415 | currX + command.cp.x, 416 | currY + command.cp.y, 417 | x += command.end.x, 418 | y += command.end.y, 419 | ); 420 | break; 421 | } 422 | case 'S': 423 | case 'Q': { 424 | this.quadraticCurveTo( 425 | command.cp.x, 426 | command.cp.y, 427 | x = command.end.x, 428 | y = command.end.y, 429 | ); 430 | break; 431 | } 432 | // The arc and arcTo commands are incompatible 433 | // with SVG (mostly because elliptical arcs) 434 | // so we normalize arcs from SVG into bezier curves 435 | case 'a': { 436 | arcToBezier({ 437 | px: x, 438 | py: y, 439 | cx: x += command.end.x, 440 | cy: y += command.end.y, 441 | rx: command.radii.x, 442 | ry: command.radii.y, 443 | xAxisRotation: command.rotation, 444 | largeArcFlag: command.large ? 1 : 0, 445 | sweepFlag: command.clockwise ? 1 : 0, 446 | }).forEach(({ x1, y1, x2, y2, x, y }) => 447 | this.bezierCurveTo(x1, y1, x2, y2, x, y)); 448 | break; 449 | } 450 | case 'A': { 451 | arcToBezier({ 452 | px: x, 453 | py: y, 454 | cx: x = command.end.x, 455 | cy: y = command.end.y, 456 | rx: command.radii.x, 457 | ry: command.radii.y, 458 | xAxisRotation: command.rotation, 459 | largeArcFlag: command.large ? 1 : 0, 460 | sweepFlag: command.clockwise ? 1 : 0, 461 | }).forEach(({ x1, y1, x2, y2, x, y }) => 462 | this.bezierCurveTo(x1, y1, x2, y2, x, y)); 463 | break; 464 | } 465 | default: { 466 | // eslint-disable-next-line no-console 467 | console.info('[PIXI.SVG] Draw command not supported:', command.code, command); 468 | break; 469 | } 470 | } 471 | } 472 | } 473 | } 474 | 475 | export { SVG }; 476 | --------------------------------------------------------------------------------