├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ ├── semantic.yaml │ └── update-deps.yaml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── package.json ├── packages ├── path2d-polyfill │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierignore copy │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src │ │ ├── __test__ │ │ │ ├── polyfill.spec.ts │ │ │ └── test-types.ts │ │ ├── index.ts │ │ └── polyfill.ts │ ├── test │ │ ├── arc.js │ │ ├── bezier.js │ │ ├── clip.js │ │ ├── close.js │ │ ├── ellipse.js │ │ ├── path2d-methods.js │ │ ├── point-inside.js │ │ ├── rounded-rect.js │ │ └── test.html │ ├── tsconfig.eslint.json │ ├── tsconfig.json │ ├── vite.config.ts │ └── vitest.config.ts └── path2d │ ├── .eslintrc.cjs │ ├── .prettierignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── __test__ │ │ ├── parse-path.spec.ts │ │ ├── path2d.spec.ts │ │ ├── round-rect.spec.ts │ │ └── test-types.ts │ ├── apply.ts │ ├── index.ts │ ├── parse-path.ts │ ├── path2d.ts │ ├── round-rect.ts │ └── types.ts │ ├── tsconfig.build.json │ ├── tsconfig.eslint.json │ ├── tsconfig.json │ └── vitest.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch" 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 2 space indentation 12 | [*.*] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | pull_request: 4 | branches: 5 | - "main" 6 | push: 7 | branches: 8 | - "main" 9 | tags-ignore: 10 | - "v*.*.*" 11 | 12 | jobs: 13 | skip-check: 14 | if: ${{ !contains(github.event.pull_request.title, 'chore(release):') }} 15 | runs-on: ubuntu-latest 16 | steps: 17 | - run: echo "run only on non-release commits" 18 | validate: 19 | needs: [skip-check] 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: pnpm/action-setup@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: pnpm 28 | - run: pnpm install 29 | - run: pnpm build 30 | - run: pnpm format:check 31 | - run: pnpm lint 32 | - run: pnpm check-types 33 | - run: pnpm test 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | token: ${{ secrets.GH_ACCESS_TOKEN }} 20 | - name: Git Config 21 | run: | 22 | git config user.name "nilzona" 23 | git config user.email "nilssonanders79@gmail.com" 24 | - uses: pnpm/action-setup@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | cache: pnpm 29 | - name: Install Dependencies 30 | run: pnpm i 31 | - name: Build Packages 32 | run: pnpm build 33 | - name: Create Release Pull Request or Publish to Qlik GitHub Packages npm registry 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | setupGitUser: false 38 | commit: "chore(release): version packages" 39 | title: "chore(release): version packages" 40 | publish: pnpm publish-packages 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/semantic.yaml: -------------------------------------------------------------------------------- 1 | name: "Semantic PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v4 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/update-deps.yaml: -------------------------------------------------------------------------------- 1 | name: Update Dependencies 2 | 3 | on: 4 | schedule: 5 | - cron: "0 4 * * 6" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-dependencies: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | token: ${{ secrets.GH_ACCESS_TOKEN }} 16 | - name: Git Config 17 | run: | 18 | git config user.name "svc-pipeline" 19 | git config user.email "svc_pipeline@qlik.com" 20 | - uses: pnpm/action-setup@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: pnpm 25 | - name: Install Dependencies 26 | run: pnpm i 27 | - name: Update Dependencies 28 | run: pnpm update --recursive 29 | - name: Create Pull Request 30 | id: cpr 31 | uses: peter-evans/create-pull-request@v4 32 | with: 33 | token: ${{ secrets.GH_ACCESS_TOKEN }} 34 | commit-message: "chore: update npm dependencies" 35 | committer: svc-pipeline 36 | author: svc-pipeline 37 | branch: chore/update-dependencies 38 | delete-branch: true 39 | title: "chore: update npm dependencies" 40 | - name: Enable Pull Request Automerge 41 | if: steps.cpr.outputs.pull-request-operation == 'created' 42 | uses: peter-evans/enable-pull-request-automerge@v2 43 | with: 44 | token: ${{ secrets.GH_ACCESS_TOKEN }} 45 | pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} 46 | merge-method: squash 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .eslintcache 5 | .cache 6 | .nyc_output 7 | .turbo 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": ["packages/path2d", "packages/path2d-polyfill"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Path2d with polyfill 2 | 3 | This repo is the home of two npm packages 4 | 5 | - [path2d](./packages/path2d/README.md) 6 | - [path2d-polyfill](./packages/path2d-polyfill/README.md) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "path2d-polyfill-repo", 3 | "private": true, 4 | "devDependencies": { 5 | "@changesets/cli": "^2.27.9", 6 | "turbo": "^2.2.3" 7 | }, 8 | "engines": { 9 | "node": ">=18" 10 | }, 11 | "packageManager": "pnpm@9.12.3", 12 | "pnpm": { 13 | "updateConfig": { 14 | "ignoreDependencies": [ 15 | "eslint" 16 | ] 17 | } 18 | }, 19 | "scripts": { 20 | "dev": "turbo run dev", 21 | "build": "turbo run build", 22 | "format:check": "turbo run format:check", 23 | "format:write": "turbo run format:write", 24 | "lint": "turbo run lint", 25 | "check-types": "turbo run check-types", 26 | "prepare-release": "changeset", 27 | "publish-packages": "changeset publish", 28 | "validate": "pnpm format:check & pnpm lint & pnpm test", 29 | "version-publish-packages": "changeset version && changeset publish", 30 | "test": "turbo run test" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | reportUnusedDisableDirectives: true, 4 | parserOptions: { 5 | project: "tsconfig.eslint.json", 6 | }, 7 | extends: ["@qlik/eslint-config", "@qlik/eslint-config/vitest"], 8 | rules: { 9 | "no-param-reassign": "off", 10 | }, 11 | ignorePatterns: ["dist", "test/path2d-polyfill.min.js"], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/.gitignore: -------------------------------------------------------------------------------- 1 | test/path2d-polyfill.min.js 2 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | example/path2d-polyfill.dev.js 3 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/.prettierignore copy: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | .turbo 4 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # path2d-polyfill 2 | 3 | ## 3.1.3 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [29bdc9d] 8 | - path2d@0.2.2 9 | 10 | ## 3.1.2 11 | 12 | ### Patch Changes 13 | 14 | - 751cc2e: chore: update npm dependencies 15 | - Updated dependencies [751cc2e] 16 | - path2d@0.2.1 17 | 18 | ## 3.1.1 19 | 20 | ### Patch Changes 21 | 22 | - 384b9a5: Drop the required node version in package.json `engines` field. 23 | 24 | ## 3.1.0 25 | 26 | ### Minor Changes 27 | 28 | - 2de6861: Polyfill the CanvasRenderingContext.clip() function 29 | 30 | ### Patch Changes 31 | 32 | - Updated dependencies [2de6861] 33 | - path2d@0.2.0 34 | 35 | ## 3.0.1 36 | 37 | ### Patch Changes 38 | 39 | - Updated dependencies [0e6e715] 40 | - path2d@0.1.2 41 | 42 | ## 3.0.0 43 | 44 | ### Major Changes 45 | 46 | - a2953fc: Path2D has been moved to it's own package and `path2d-polyfill` is now purely a browser library for applying the polyfill. All node related code has been moved to the `path2d`package. 47 | 48 | ```js 49 | import { Path2D } from "path2d"; 50 | ``` 51 | 52 | ## 2.1.1 53 | 54 | ### Patch Changes 55 | 56 | - 56d5f7e: Path to make releasing work 57 | - Updated dependencies [56d5f7e] 58 | - path2d@0.1.1 59 | 60 | ## 2.1.0 61 | 62 | ### Minor Changes 63 | 64 | - 0290345: Publish and use `path2d` library 65 | 66 | ### Patch Changes 67 | 68 | - Updated dependencies [0290345] 69 | - path2d@0.1.0 70 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Anders Nilsson 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/README.md: -------------------------------------------------------------------------------- 1 | # path2d-polyfill 2 | 3 | [![CI](https://github.com/nilzona/path2d-polyfill/actions/workflows/ci.yml/badge.svg)](https://github.com/nilzona/path2d-polyfill/actions/workflows/ci.yml) 4 | 5 | Polyfills `Path2D` api and `roundRect` for CanvasRenderingContext2D 6 | 7 | ## Usage 8 | 9 | Add this script tag to your page to enable the feature. 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | This will polyfill the browser's window object with Path2D features and it will also polyfill roundRect if they are missing in both CanvasRenderingContexst and Path2D. 16 | 17 | Example of usage 18 | 19 | ```javascript 20 | ctx.fill(new Path2D("M 80 80 A 45 45 0 0 0 125 125 L 125 80 Z")); 21 | ctx.stroke(new Path2D("M 80 80 A 45 45 0 0 0 125 125 L 125 80 Z")); 22 | ``` 23 | 24 | ## Support table 25 | 26 | | Method | Supported | 27 | | -------------------- | :-------: | 28 | | constructor(SVGPath) | Yes | 29 | | addPath() | Yes | 30 | | closePath() | Yes | 31 | | moveTo() | Yes | 32 | | lineTo() | Yes | 33 | | bezierCurveTo() | Yes | 34 | | quadraticCurveTo() | Yes | 35 | | arc() | Yes | 36 | | ellipse() | Yes | 37 | | rect() | Yes | 38 | | roundRect() | Yes | 39 | 40 | ## See it in action 41 | 42 | Clone [path2d-polyfill](https://github.com/nilzona/path2d-polyfill) 43 | 44 | ```shell 45 | pnpm install 46 | pnpm dev 47 | ``` 48 | 49 | open to see the example page. 50 | 51 | ## Contributing 52 | 53 | Recommended to use vscode with the prettier extension to keep formatting intact. 54 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Testing canvas 4 | 31 | 36 | 37 | 38 |
39 |
40 |
Arc example
41 | 42 |
43 |
44 |
Bezier example
45 | 46 |
47 |
48 |
Use Path2D methods
49 | 50 |
51 |
52 |
Ellipse example
53 | 54 |
55 |
56 |
Close example
57 | 58 |
59 |
60 |
Point inside example
61 | 62 |
63 |
64 |
Rounded rect example
65 | 66 |
67 |
68 |
Clip function example
69 | 70 |
71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "path2d-polyfill", 3 | "version": "3.1.3", 4 | "description": "Polyfills Path2D api for canvas rendering", 5 | "keywords": [ 6 | "Path2D", 7 | "polyfill", 8 | "canvas", 9 | "roundRect" 10 | ], 11 | "homepage": "https://github.com/nilzona/path2d-polyfill#readme", 12 | "bugs": { 13 | "url": "https://github.com/nilzona/path2d-polyfill/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/nilzona/path2d-polyfill.git" 18 | }, 19 | "license": "MIT", 20 | "author": "nilzona", 21 | "type": "module", 22 | "exports": { 23 | "browser": "./dist/path2d-polyfill.min.js" 24 | }, 25 | "browser": "dist/path2d-polyfill.min.js", 26 | "files": [ 27 | "dist" 28 | ], 29 | "scripts": { 30 | "build": "vite build && cp dist/path2d-polyfill.min.js test/path2d-polyfill.min.js", 31 | "check-types": "tsc --noEmit", 32 | "dev": "vite", 33 | "format:check": "prettier --check '**' --ignore-unknown", 34 | "format:write": "prettier --write '**' --ignore-unknown", 35 | "lint": "eslint .", 36 | "preview": "vite preview", 37 | "test": "vitest run", 38 | "test:coverage": "vitest run --coverage", 39 | "test:watch": "vitest" 40 | }, 41 | "browserslist": [ 42 | ">0.1%", 43 | "ie >= 11", 44 | "last 2 versions", 45 | "not dead" 46 | ], 47 | "prettier": "@qlik/prettier-config", 48 | "dependencies": { 49 | "path2d": "workspace:^" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.26.0", 53 | "@babel/preset-env": "^7.26.0", 54 | "@qlik/eslint-config": "^0.8.1", 55 | "@qlik/prettier-config": "^0.4.18", 56 | "@qlik/tsconfig": "^0.2.7", 57 | "@rollup/plugin-babel": "^6.0.4", 58 | "@vitest/coverage-v8": "^2.1.4", 59 | "eslint": "^8.57.1", 60 | "jsdom": "^25.0.1", 61 | "prettier": "^3.3.3", 62 | "typescript": "^5.6.3", 63 | "vite": "^5.4.10", 64 | "vitest": "^2.1.4" 65 | }, 66 | "publishConfig": { 67 | "access": "public" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/src/__test__/polyfill.spec.ts: -------------------------------------------------------------------------------- 1 | import { Path2D } from "path2d"; 2 | import { afterEach, beforeAll, describe, expect, it } from "vitest"; 3 | import { polyfillPath2D } from "../polyfill"; 4 | import { CanvasRenderingContext2DForTest } from "./test-types"; 5 | 6 | describe("polyfill", () => { 7 | window.CanvasRenderingContext2D = 8 | CanvasRenderingContext2DForTest as unknown as typeof window.CanvasRenderingContext2D; 9 | window.Path2D = Path2D as unknown as typeof window.Path2D; 10 | 11 | let globalCTX: typeof window.CanvasRenderingContext2D; 12 | let globalPath2D: typeof window.Path2D; 13 | let CTXRoundRect: typeof window.CanvasRenderingContext2D.prototype.roundRect; 14 | let Path2DRoundRect: typeof window.Path2D.prototype.roundRect; 15 | 16 | beforeAll(() => { 17 | globalCTX = window.CanvasRenderingContext2D; 18 | globalPath2D = window.Path2D; 19 | CTXRoundRect = window.CanvasRenderingContext2D.prototype.roundRect; 20 | Path2DRoundRect = window.Path2D.prototype.roundRect; 21 | }); 22 | afterEach(() => { 23 | window.CanvasRenderingContext2D = globalCTX; 24 | window.Path2D = globalPath2D; 25 | window.CanvasRenderingContext2D.prototype.roundRect = CTXRoundRect; 26 | window.Path2D.prototype.roundRect = Path2DRoundRect; 27 | }); 28 | 29 | it("should not add Path2D if window.CanvasRenderingContext2D is undefined", () => { 30 | // @ts-expect-error for testing purpose 31 | window.CanvasRenderingContext2D = undefined; 32 | // @ts-expect-error for testing purpose 33 | window.Path2D = undefined; 34 | polyfillPath2D(); 35 | expect(window.Path2D).toBeUndefined(); 36 | }); 37 | 38 | it("should add Path2D constructor to window object", () => { 39 | // @ts-expect-error for testing purpose 40 | window.Path2D = undefined; 41 | polyfillPath2D(); 42 | const P2D = new window.Path2D(); 43 | expect(P2D).toBeInstanceOf(Path2D); 44 | }); 45 | 46 | it("should add a roundRect to CanvasRenderingContext", () => { 47 | // @ts-expect-error for testing purpose 48 | window.CanvasRenderingContext2D.prototype.roundRect = undefined; 49 | // @ts-expect-error for testing purpose 50 | window.Path2D.prototype.roundRect = undefined; 51 | polyfillPath2D(); 52 | expect(window.CanvasRenderingContext2D.prototype.roundRect).toBeTypeOf("function"); 53 | expect(window.Path2D.prototype.roundRect).toBeTypeOf("function"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/src/__test__/test-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { type CanvasFillRule, type ICanvasRenderingContext2D, type Path2D } from "path2d"; 3 | 4 | export class CanvasRenderingContext2DForTest implements ICanvasRenderingContext2D { 5 | strokeStyle: string | null; 6 | lineWidth: number | null; 7 | constructor() { 8 | this.strokeStyle = null; 9 | this.lineWidth = null; 10 | } 11 | clip(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 12 | fill(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 13 | stroke(a?: CanvasFillRule | Path2D) {} 14 | isPointInPath(a: number | Path2D, b: number, c?: number | CanvasFillRule, d?: number | CanvasFillRule) { 15 | return false; 16 | } 17 | beginPath() {} 18 | moveTo() {} 19 | lineTo() {} 20 | ellipse() {} 21 | arc() {} 22 | arcTo() {} 23 | closePath() {} 24 | bezierCurveTo() {} 25 | quadraticCurveTo() {} 26 | rect() {} 27 | roundRect() {} 28 | save() {} 29 | translate() {} 30 | rotate() {} 31 | scale() {} 32 | restore() {} 33 | } 34 | 35 | export type IPrototype = { 36 | prototype: T; 37 | new (): T; 38 | }; 39 | 40 | export interface Global { 41 | CanvasRenderingContext2D: typeof CanvasRenderingContext2DForTest; 42 | Path2D: typeof Path2D; 43 | } 44 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/src/index.ts: -------------------------------------------------------------------------------- 1 | import { polyfillPath2D } from "./polyfill"; 2 | 3 | // invoke polyfill upon loading 4 | polyfillPath2D(); 5 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/src/polyfill.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Path2D, 3 | applyPath2DToCanvasRenderingContext, 4 | applyRoundRectToCanvasRenderingContext2D, 5 | applyRoundRectToPath2D, 6 | } from "path2d"; 7 | 8 | export function polyfillPath2D() { 9 | if (window) { 10 | if (window.CanvasRenderingContext2D && !window.Path2D) { 11 | // @ts-expect-error polyfilling 12 | window.Path2D = Path2D; 13 | applyPath2DToCanvasRenderingContext(window.CanvasRenderingContext2D); 14 | } 15 | applyRoundRectToPath2D(window.Path2D); 16 | applyRoundRectToCanvasRenderingContext2D(window.CanvasRenderingContext2D); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/arc.js: -------------------------------------------------------------------------------- 1 | function draw2circles(ctx, x1, y1, x2, y2, r) { 2 | ctx.strokeStyle = "#999999"; 3 | ctx.lineWidth = 1; 4 | ctx.beginPath(); 5 | ctx.arc(x1, y1, r, 0, 2 * Math.PI); 6 | ctx.stroke(); 7 | ctx.beginPath(); 8 | ctx.arc(x2, y2, r, 0, 2 * Math.PI); 9 | ctx.stroke(); 10 | } 11 | 12 | (function run() { 13 | const canvas = document.getElementById("arc-canvas"); 14 | const ctx = canvas?.getContext("2d"); 15 | if (ctx) { 16 | ctx.translate(50, 50); 17 | ctx.strokeStyle = "black"; 18 | draw2circles(ctx, 125, 80, 80, 125, 45); 19 | const path1 = new Path2D("M 80 80 A 45 45 0 0 0 125 125 L 125 80 Z"); 20 | ctx.fillStyle = "rgba(0, 255, 0, 0.8)"; 21 | ctx.lineWidth = 2; 22 | ctx.stroke(path1); 23 | ctx.fill(path1); 24 | draw2circles(ctx, 275, 80, 230, 125, 45); 25 | const path2 = new Path2D("M 230 80 A 45 45 0 1 0 275 125 L 275 80 Z"); 26 | ctx.fillStyle = "rgba(255, 0, 0, 0.8)"; 27 | ctx.lineWidth = 2; 28 | ctx.stroke(path2); 29 | ctx.fill(path2); 30 | draw2circles(ctx, 125, 230, 80, 275, 45); 31 | const path3 = new Path2D("M80 230 A 45 45 0 0 1 125 275 L 125 230 Z"); 32 | ctx.fillStyle = "rgba(128, 0, 128, 0.8)"; 33 | ctx.lineWidth = 2; 34 | ctx.stroke(path3); 35 | ctx.fill(path3); 36 | draw2circles(ctx, 275, 230, 230, 275, 45); 37 | const path4 = new Path2D("M230 230 A 45 45 0 1 1 275 275 L 275 230 Z"); 38 | ctx.fillStyle = "rgba(0, 0, 255, 0.8)"; 39 | ctx.lineWidth = 2; 40 | ctx.stroke(path4); 41 | ctx.fill(path4); 42 | } 43 | })(); 44 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/bezier.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("bezier-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | ctx.strokeStyle = "black"; 6 | ctx.stroke(new Path2D("M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80")); 7 | ctx.translate(200, 0); 8 | ctx.stroke(new Path2D("M10 80 Q 95 10 180 80")); 9 | ctx.translate(-200, 200); 10 | ctx.stroke(new Path2D("M10 80 Q 52.5 10, 95 80 T 180 80")); 11 | } 12 | })(); 13 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/clip.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("clip-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | // Create clipping path 6 | const region = new Path2D(); 7 | region.rect(80, 10, 20, 130); 8 | region.rect(40, 50, 100, 50); 9 | ctx.clip(region, "evenodd"); 10 | 11 | // Draw stuff that gets clipped 12 | ctx.fillStyle = "blue"; 13 | ctx.fillRect(0, 0, canvas.width, canvas.height); 14 | } 15 | })(); 16 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/close.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("close-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | const path = new Path2D("M20,20h100v100Zm120,0h100v100Z"); 6 | ctx.strokeStyle = "#666"; 7 | ctx.fillStyle = "#DDD"; 8 | ctx.fill(path); 9 | ctx.stroke(path); 10 | } 11 | })(); 12 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/ellipse.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("ellipse-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | ctx.translate(50, 50); 6 | ctx.strokeStyle = "red"; 7 | const path1 = new Path2D("M 10 90 l 40 -40"); 8 | ctx.stroke(path1); 9 | ctx.strokeStyle = "black"; 10 | const path2 = new Path2D("M 10 90 a 30 50 -20 1 0 40 -40"); 11 | ctx.stroke(path2); 12 | ctx.strokeStyle = "black"; 13 | const path3 = new Path2D("M 10 90 a 40 60 20 1 1 40 -40"); 14 | ctx.stroke(path3); 15 | ctx.strokeStyle = "blue"; 16 | const path4 = new Path2D("M 10 90 a 30 50 -20 0 1 40 -40"); 17 | ctx.stroke(path4); 18 | ctx.strokeStyle = "blue"; 19 | const path5 = new Path2D("M 10 90 a 40 60 20 0 0 40 -40"); 20 | ctx.stroke(path5); 21 | ctx.strokeStyle = "black"; 22 | const path6 = new Path2D( 23 | "M0,300 l 30,-15" + 24 | "a15,15 -30 0,1 30,-15 l 30,-15" + 25 | "a15,30 -30 0,1 30,-15 l 30,-15" + 26 | "a15,45 -30 0,1 30,-15 l 30,-15" + 27 | "a15,60 -30 0,1 30,-15 l 30,-15", 28 | ); 29 | ctx.stroke(path6); 30 | ctx.strokeStyle = "black"; 31 | const path7 = new Path2D( 32 | "a15,15 -30 0,1 30,-15 l 30,-15" + 33 | "a15,30 -30 0,1 30,-15 l 30,-15" + 34 | "a15,45 -30 0,1 30,-15 l 30,-15" + 35 | "a15,60 -30 0,1 30,-15 l 30,-15", 36 | ); 37 | ctx.stroke(path7); 38 | } 39 | })(); 40 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/path2d-methods.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("path2d-methods-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | const path1 = new Path2D(); 6 | path1.arc(75, 75, 50, 0, Math.PI); 7 | path1.lineTo(75, 50); 8 | path1.lineTo(100, 60); 9 | path1.closePath(); 10 | ctx.strokeStyle = "#999999"; 11 | ctx.fillStyle = "rgba(0, 255, 0, 0.8)"; 12 | ctx.lineWidth = 1; 13 | ctx.stroke(path1); 14 | ctx.fill(path1); 15 | ctx.translate(100, 0); 16 | const path2 = new Path2D(); 17 | path2.moveTo(75, 75); 18 | path2.lineTo(125, 75); 19 | path2.lineTo(125, 125); 20 | path2.lineTo(75, 125); 21 | path2.closePath(); 22 | ctx.fillStyle = "rgba(0, 0, 255, 0.8)"; 23 | ctx.stroke(path2); 24 | ctx.fill(path2); 25 | ctx.translate(-100, 100); 26 | const path3 = new Path2D(); 27 | path3.rect(75, 75, 80, 50); 28 | ctx.fillStyle = "rgba(255, 0, 255, 0.8)"; 29 | ctx.stroke(path3); 30 | ctx.fill(path3); 31 | ctx.translate(100, 0); 32 | const path4 = new Path2D(path3); 33 | ctx.fillStyle = "rgba(0, 255, 255, 0.8)"; 34 | ctx.stroke(path4); 35 | ctx.fill(path4); 36 | // arc 37 | ctx.translate(100, -50); 38 | const path5 = new Path2D(); 39 | path5.moveTo(150, 20); 40 | path5.arcTo(150, 100, 50, 20, 30); 41 | path5.lineTo(50, 20); 42 | path5.closePath(); 43 | ctx.fillStyle = "rgba(255, 255, 0, 0.8)"; 44 | ctx.stroke(path5); 45 | ctx.fill(path5); 46 | // ellipse 47 | ctx.translate(0, 50); 48 | const path6 = new Path2D(); 49 | path6.ellipse(150, 120, 60, 40, Math.PI / 4, 0, (5 * Math.PI) / 6, true); 50 | path6.closePath(); 51 | ctx.fillStyle = "rgba(255, 0, 0, 0.8)"; 52 | ctx.stroke(path6); 53 | ctx.fill(path6); 54 | } 55 | })(); 56 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/point-inside.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("point-inside-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | // even odd path 6 | const region = new Path2D("M 30 90 L 110 20 L 240 130 L 60 130 L 190 20 L 270 90 Z"); 7 | ctx.fillStyle = "red"; 8 | ctx.fill(region, "evenodd"); 9 | // Listen for mouse moves 10 | canvas.addEventListener("mousemove", (event) => { 11 | // Check whether point is inside region 12 | if (ctx.isPointInPath(region, event.offsetX, event.offsetY, "evenodd")) { 13 | ctx.fillStyle = "green"; 14 | } else { 15 | ctx.fillStyle = "red"; 16 | } 17 | // Draw region 18 | ctx.clearRect(0, 0, canvas.width, canvas.height); 19 | ctx.fill(region, "evenodd"); 20 | }); 21 | } 22 | })(); 23 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/rounded-rect.js: -------------------------------------------------------------------------------- 1 | (function run() { 2 | const canvas = document.getElementById("rounded-rect-canvas"); 3 | const ctx = canvas?.getContext("2d"); 4 | if (ctx) { 5 | ctx.translate(30, 30); 6 | ctx.fillStyle = "rgba(155, 0, 255, 0.8)"; 7 | ctx.strokeStyle = "black"; 8 | // roundRect, 4 radiuses 9 | const path1 = new Path2D(); 10 | path1.roundRect(0, 0, 80, 50, [5, 10, 20, 30]); 11 | ctx.stroke(path1); 12 | ctx.fill(path1); 13 | // roundRect, 3 radiuses 14 | ctx.translate(100, 0); 15 | const path2 = new Path2D(); 16 | path2.roundRect(0, 0, 80, 50, [5, 10, 20]); 17 | ctx.stroke(path2); 18 | ctx.fill(path2); 19 | // roundRect, 2 radiuses 20 | ctx.translate(100, 0); 21 | const path3 = new Path2D(); 22 | path3.roundRect(0, 0, 80, 50, [5, 10]); 23 | ctx.stroke(path3); 24 | ctx.fill(path3); 25 | // roundRect, 1 radius 26 | ctx.translate(100, 0); 27 | const path4 = new Path2D(); 28 | path4.roundRect(0, 0, 80, 50, [5]); 29 | ctx.stroke(path4); 30 | ctx.fill(path4); 31 | // roundRect, radius as number 32 | ctx.translate(-300, 100); 33 | const path5 = new Path2D(); 34 | path5.roundRect(0, 0, 80, 50, 5); 35 | ctx.stroke(path5); 36 | ctx.fill(path5); 37 | // roundRect, no radius 38 | ctx.translate(100, 0); 39 | const path6 = new Path2D(); 40 | path6.roundRect(0, 0, 80, 50); 41 | ctx.stroke(path6); 42 | ctx.fill(path6); 43 | 44 | // round rect with big linewidth 45 | ctx.translate(100, 0); 46 | const path7 = new Path2D(); 47 | path7.roundRect(0, 0, 80, 50, [15, 15, 15, 0]); 48 | const temp = ctx.lineWidth; 49 | ctx.lineWidth = 10; 50 | ctx.stroke(path7); 51 | ctx.lineWidth = temp; 52 | // roundRect when path already has begun with other elements and with more path elements after 53 | ctx.translate(-100, 100); 54 | const path8 = new Path2D(); 55 | path8.moveTo(0, 75); 56 | path8.lineTo(50, 75); 57 | path8.lineTo(0, 125); 58 | path8.roundRect(0, 0, 80, 50, [5]); 59 | path8.lineTo(-50, 50); 60 | path8.lineTo(-50, 0); 61 | ctx.stroke(path8); 62 | ctx.fill(path8); 63 | // draw roundRect directly in canvas 64 | ctx.translate(-100, 175); 65 | ctx.roundRect(0, 0, 80, 50, [5, 10, 20, 30]); 66 | ctx.translate(100, 0); 67 | ctx.roundRect(0, 0, 80, 50, [5, 10, 20]); 68 | ctx.translate(100, 0); 69 | ctx.roundRect(0, 0, 80, 50, [5, 10]); 70 | ctx.translate(100, 0); 71 | ctx.roundRect(0, 0, 80, 50, [5]); 72 | ctx.stroke(); 73 | ctx.fill(); 74 | } 75 | })(); 76 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/test/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Testing canvas 4 | 31 | 36 | 37 | 38 |
39 |
40 |
Arc example
41 | 42 |
43 |
44 |
Bezier example
45 | 46 |
47 |
48 |
Use Path2D methods
49 | 50 |
51 |
52 |
Ellipse example
53 | 54 |
55 |
56 |
Close example
57 | 58 |
59 |
60 |
Point inside example
61 | 62 |
63 |
64 |
Rounded rect example
65 | 66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".*", "**/*"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@qlik/tsconfig/recommended.json", 3 | "include": ["src", "test"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/vite.config.ts: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import { resolve } from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | // Could also be a dictionary or array of multiple entry points 9 | entry: resolve(__dirname, "src/index.ts"), 10 | name: "PATH2D_POLYFILL", 11 | // the proper extensions will be added 12 | fileName: () => "path2d-polyfill.min.js", 13 | formats: ["iife"], 14 | }, 15 | rollupOptions: { 16 | plugins: [ 17 | babel({ 18 | presets: ["@babel/preset-env"], 19 | babelHelpers: "bundled", 20 | }), 21 | ], 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/path2d-polyfill/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | exclude: ["**/node_modules/**", "dist"], 7 | environment: "jsdom", 8 | coverage: { 9 | exclude: ["*.cjs", "test", "src/__test__/**", "src/index.ts"], 10 | }, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/path2d/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | reportUnusedDisableDirectives: true, 4 | parserOptions: { 5 | project: "tsconfig.eslint.json", 6 | }, 7 | extends: ["@qlik/eslint-config", "@qlik/eslint-config/vitest"], 8 | rules: { 9 | "no-param-reassign": "off", 10 | }, 11 | ignorePatterns: ["dist"], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/path2d/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | .turbo 4 | -------------------------------------------------------------------------------- /packages/path2d/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # path2d 2 | 3 | ## 0.2.2 4 | 5 | ### Patch Changes 6 | 7 | - 29bdc9d: Fix rounded rect bottom-right corner 8 | 9 | ## 0.2.1 10 | 11 | ### Patch Changes 12 | 13 | - 751cc2e: chore: update npm dependencies 14 | 15 | ## 0.2.0 16 | 17 | ### Minor Changes 18 | 19 | - 2de6861: Polyfill the CanvasRenderingContext.clip() function 20 | 21 | ## 0.1.2 22 | 23 | ### Patch Changes 24 | 25 | - 0e6e715: Make types compatible with node-canvas 26 | 27 | ## 0.1.1 28 | 29 | ### Patch Changes 30 | 31 | - 56d5f7e: Path to make releasing work 32 | 33 | ## 0.1.0 34 | 35 | ### Minor Changes 36 | 37 | - 0290345: Publish and use `path2d` library 38 | -------------------------------------------------------------------------------- /packages/path2d/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Anders Nilsson 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/path2d/README.md: -------------------------------------------------------------------------------- 1 | # path2d 2 | 3 | [![CI](https://github.com/nilzona/path2d-polyfill/actions/workflows/ci.yml/badge.svg)](https://github.com/nilzona/path2d-polyfill/actions/workflows/ci.yml) 4 | 5 | Implements `Path2D` api and `roundRect` for CanvasRenderingContext2D 6 | 7 | ## Usage 8 | 9 | ```shell 10 | npm install --save path2d 11 | ``` 12 | 13 | ## Use in a node environment 14 | 15 | The package exports a few functions that can be used in a node environment: 16 | 17 | - `Path2D` - class to create Path2D objects used by the polyfill methods 18 | - `parsePath` - function for parsing an SVG path string into canvas commands 19 | - `roundRect` - implementation of roundRect using canvas commands 20 | - `applyPath2DToCanvasRenderingContext` - Adds Path2D functions (if needed) to a CanvasRenderingContext and augments the fill, stroke and clip command 21 | - `applyRoundRectToCanvasRenderingContext2D` - Adds roundRect function (if needed) to a CanvasRenderingContext 22 | 23 | ```js 24 | import { Path2D } from "path2d"; 25 | ``` 26 | 27 | ### usage with node-canvas 28 | 29 | To get Path2D features with the [node-canvas library](https://github.com/Automattic/node-canvas) use the following pattern: 30 | 31 | ```js 32 | const { createCanvas, CanvasRenderingContext2D } = require("canvas"); 33 | const { applyPath2DToCanvasRenderingContext, Path2D } = require("path2d"); 34 | 35 | applyPath2DToCanvasRenderingContext(CanvasRenderingContext2D); 36 | // Path2D features has now been added to CanvasRenderingContext2D 37 | 38 | const canvas = createCanvas(200, 200); 39 | const ctx = canvas.getContext("2d"); 40 | 41 | const p = new Path2D("M10 10 l 20 0 l 0 20 Z"); 42 | ctx.fillStyle = "green"; 43 | ctx.fill(p); 44 | ``` 45 | 46 | A working example of a node express server that serves an image drawn with canvas can be seen [here](https://gist.github.com/nilzona/e611c99336d8ea1f645bd391a459c24f) 47 | 48 | ## Support table 49 | 50 | | Method | Supported | 51 | | -------------------- | :-------: | 52 | | constructor(SVGPath) | Yes | 53 | | addPath() | Yes | 54 | | closePath() | Yes | 55 | | moveTo() | Yes | 56 | | lineTo() | Yes | 57 | | bezierCurveTo() | Yes | 58 | | quadraticCurveTo() | Yes | 59 | | arc() | Yes | 60 | | ellipse() | Yes | 61 | | rect() | Yes | 62 | | roundRect() | Yes | 63 | 64 | ## See it in action 65 | 66 | Clone [path2d-polyfill](https://github.com/nilzona/path2d-polyfill) 67 | 68 | ```shell 69 | pnpm install 70 | pnpm dev 71 | ``` 72 | 73 | open to see the example page. 74 | 75 | ## Contributing 76 | 77 | Recommended to use vscode with the prettier extension to keep formatting intact. 78 | -------------------------------------------------------------------------------- /packages/path2d/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "path2d", 3 | "version": "0.2.2", 4 | "description": "Path2D API for node. Can be used for server-side rendering with canvas", 5 | "keywords": [ 6 | "Path2D", 7 | "polyfill", 8 | "canvas", 9 | "roundRect" 10 | ], 11 | "homepage": "https://github.com/nilzona/path2d-polyfill#readme", 12 | "bugs": { 13 | "url": "https://github.com/nilzona/path2d-polyfill/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/nilzona/path2d-polyfill.git" 18 | }, 19 | "license": "MIT", 20 | "author": "nilzona", 21 | "type": "module", 22 | "exports": { 23 | ".": { 24 | "import": "./src/index.ts" 25 | } 26 | }, 27 | "main": "dist/index.cjs", 28 | "module": "./dist/index.js", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "build": "tsup-node src/index.ts --target node18 --format esm,cjs --dts", 34 | "check-types": "tsc --noEmit", 35 | "format:check": "prettier --check '**' --ignore-unknown", 36 | "format:write": "prettier --write '**' --ignore-unknown", 37 | "lint": "eslint .", 38 | "test": "vitest run", 39 | "test:coverage": "vitest run --coverage", 40 | "test:watch": "vitest", 41 | "watch": "pnpm build --watch" 42 | }, 43 | "prettier": "@qlik/prettier-config", 44 | "devDependencies": { 45 | "@qlik/eslint-config": "0.8.1", 46 | "@qlik/prettier-config": "^0.4.18", 47 | "@qlik/tsconfig": "^0.2.7", 48 | "@swc/core": "^1.9.1", 49 | "@types/node": "22.9.0", 50 | "@vitest/coverage-v8": "2.1.4", 51 | "eslint": "^8.57.1", 52 | "prettier": "^3.3.3", 53 | "rimraf": "6.0.1", 54 | "tsup": "^8.3.5", 55 | "typescript": "^5.6.3", 56 | "vitest": "2.1.4" 57 | }, 58 | "engines": { 59 | "node": ">=6" 60 | }, 61 | "publishConfig": { 62 | "access": "public", 63 | "exports": { 64 | ".": { 65 | "import": "./dist/index.js", 66 | "require": "./dist/index.cjs" 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/path2d/src/__test__/parse-path.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test } from "vitest"; 2 | import { parsePath } from "../parse-path.js"; 3 | 4 | describe("parse-path", () => { 5 | // https://www.w3.org/TR/SVG/paths.html#PathDataGeneralInformation 6 | let path = ""; 7 | let ary; 8 | 9 | beforeEach(() => { 10 | path = ""; 11 | ary = []; 12 | }); 13 | 14 | test("path data segement must begin with a moveTo command", () => { 15 | path = "L3 4 L5 6"; 16 | ary = parsePath(path); 17 | expect(ary).toEqual([]); 18 | }); 19 | 20 | test("should join multiple commands", () => { 21 | path = "M1 2 L3 4 Q5 6 7 8 Z"; 22 | ary = parsePath(path); 23 | expect(ary).toEqual([["M", 1, 2], ["L", 3, 4], ["Q", 5, 6, 7, 8], ["Z"]]); 24 | }); 25 | 26 | test("should be possible to chain command parameters", () => { 27 | path = "M0 0 L 1 2 3 4"; 28 | ary = parsePath(path); 29 | expect(ary).toEqual([ 30 | ["M", 0, 0], 31 | ["L", 1, 2], 32 | ["L", 3, 4], 33 | ]); 34 | 35 | path = "M0 0 L 1 2 3 4 5"; // Only accept pairs 36 | ary = parsePath(path); 37 | expect(ary).toEqual([ 38 | ["M", 0, 0], 39 | ["L", 1, 2], 40 | ["L", 3, 4], 41 | ]); 42 | }); 43 | 44 | test("superfluous white space and separators such as commas can be eliminated", () => { 45 | path = "M0 0 L ,1 ,2"; 46 | ary = parsePath(path); 47 | expect(ary).toEqual([ 48 | ["M", 0, 0], 49 | ["L", 1, 2], 50 | ]); 51 | 52 | path = "M0 0 L 1 2"; 53 | ary = parsePath(path); 54 | expect(ary).toEqual([ 55 | ["M", 0, 0], 56 | ["L", 1, 2], 57 | ]); 58 | }); 59 | 60 | test("should discard invalid commands", () => { 61 | path = "M 0 0 L 1 2, M3 4 Ö5, L 7 8"; 62 | ary = parsePath(path); 63 | expect(ary).toEqual([ 64 | ["M", 0, 0], 65 | ["L", 1, 2], 66 | ["M", 3, 4], 67 | ["L", 7, 8], 68 | ]); 69 | 70 | path = "M 0 0 L1 2, L3 4 Ö5 6, L7 8"; 71 | ary = parsePath(path); 72 | expect(ary).toEqual([ 73 | ["M", 0, 0], 74 | ["L", 1, 2], 75 | ["L", 3, 4], 76 | ["L", 5, 6], // Should not do this and instead ignore this command as well? 77 | ["L", 7, 8], 78 | ]); 79 | }); 80 | 81 | describe("moveTo", () => { 82 | test("M", () => { 83 | path = "M123 456"; 84 | ary = parsePath(path); 85 | expect(ary).toEqual([["M", 123, 456]]); 86 | }); 87 | 88 | test("m", () => { 89 | path = "m123 456"; 90 | ary = parsePath(path); 91 | expect(ary).toEqual([["m", 123, 456]]); 92 | }); 93 | 94 | test("subsequent pairs are treated as implicit lineTo commands", () => { 95 | path = "M1 2 3 4"; 96 | ary = parsePath(path); 97 | expect(ary).toEqual([ 98 | ["M", 1, 2], 99 | ["L", 3, 4], 100 | ]); 101 | 102 | path = "m1 2 3 4"; 103 | ary = parsePath(path); 104 | expect(ary).toEqual([ 105 | ["m", 1, 2], 106 | ["l", 3, 4], 107 | ]); 108 | 109 | path = "m1 2 3"; // Invalid length on subsequnt paramaters 110 | ary = parsePath(path); 111 | expect(ary).toEqual([["m", 1, 2]]); 112 | }); 113 | 114 | test("exceed max parameters", () => { 115 | path = "M 1 2 3"; 116 | ary = parsePath(path); 117 | expect(ary).toEqual([["M", 1, 2]]); 118 | }); 119 | 120 | test("below parameter limit", () => { 121 | path = "M 1"; 122 | ary = parsePath(path); 123 | expect(ary).toEqual([]); 124 | }); 125 | }); 126 | 127 | describe("lineTo", () => { 128 | test("L", () => { 129 | path = "M0 0 L123 456 L 987 6"; 130 | ary = parsePath(path); 131 | expect(ary).toEqual([ 132 | ["M", 0, 0], 133 | ["L", 123, 456], 134 | ["L", 987, 6], 135 | ]); 136 | }); 137 | 138 | test("l", () => { 139 | path = "M0 0 l123 456 l 987 6"; 140 | ary = parsePath(path); 141 | expect(ary).toEqual([ 142 | ["M", 0, 0], 143 | ["l", 123, 456], 144 | ["l", 987, 6], 145 | ]); 146 | }); 147 | 148 | test("V", () => { 149 | path = "M0 0 V1 V2"; 150 | ary = parsePath(path); 151 | expect(ary).toEqual([ 152 | ["M", 0, 0], 153 | ["V", 1], 154 | ["V", 2], 155 | ]); 156 | }); 157 | 158 | test("v", () => { 159 | path = "M0 0 v1 v2"; 160 | ary = parsePath(path); 161 | expect(ary).toEqual([ 162 | ["M", 0, 0], 163 | ["v", 1], 164 | ["v", 2], 165 | ]); 166 | }); 167 | 168 | test("H", () => { 169 | path = "M0 0 H1 H2"; 170 | ary = parsePath(path); 171 | expect(ary).toEqual([ 172 | ["M", 0, 0], 173 | ["H", 1], 174 | ["H", 2], 175 | ]); 176 | }); 177 | 178 | test("h", () => { 179 | path = "M0 0 h1 h2"; 180 | ary = parsePath(path); 181 | expect(ary).toEqual([ 182 | ["M", 0, 0], 183 | ["h", 1], 184 | ["h", 2], 185 | ]); 186 | }); 187 | 188 | test("exceed max parameters", () => { 189 | path = "M0 0 L 1 2 3"; 190 | ary = parsePath(path); 191 | expect(ary).toEqual([ 192 | ["M", 0, 0], 193 | ["L", 1, 2], 194 | ]); 195 | }); 196 | 197 | test("L - below parameter limit", () => { 198 | path = "M0 0 L 1"; 199 | ary = parsePath(path); 200 | expect(ary).toEqual([["M", 0, 0]]); 201 | }); 202 | 203 | test("V - below parameter limit", () => { 204 | path = "M0 0 V"; 205 | ary = parsePath(path); 206 | expect(ary).toEqual([["M", 0, 0]]); 207 | }); 208 | 209 | test("H - below parameter limit", () => { 210 | path = "M0 0 H"; 211 | ary = parsePath(path); 212 | expect(ary).toEqual([["M", 0, 0]]); 213 | }); 214 | }); 215 | 216 | describe("closePath", () => { 217 | test("Z", () => { 218 | path = "M0 0 Z"; 219 | ary = parsePath(path); 220 | expect(ary).toEqual([["M", 0, 0], ["Z"]]); 221 | }); 222 | 223 | test("z", () => { 224 | path = "M0 0 z"; 225 | ary = parsePath(path); 226 | expect(ary).toEqual([["M", 0, 0], ["z"]]); 227 | }); 228 | 229 | test("exceed max parameters", () => { 230 | path = "M0 0 Z 1"; 231 | ary = parsePath(path); 232 | expect(ary).toEqual([["M", 0, 0], ["Z"]]); 233 | }); 234 | }); 235 | 236 | describe("cubic Bézier curve", () => { 237 | test("C", () => { 238 | path = "M 0 0 C 1 2 3 4 5 6"; 239 | ary = parsePath(path); 240 | expect(ary).toEqual([ 241 | ["M", 0, 0], 242 | ["C", 1, 2, 3, 4, 5, 6], 243 | ]); 244 | }); 245 | 246 | test("c", () => { 247 | path = "M0 0 c 1 2 3 4 5 6"; 248 | ary = parsePath(path); 249 | expect(ary).toEqual([ 250 | ["M", 0, 0], 251 | ["c", 1, 2, 3, 4, 5, 6], 252 | ]); 253 | }); 254 | 255 | test("S", () => { 256 | path = "M0 0 S 1 2 3 4"; 257 | ary = parsePath(path); 258 | expect(ary).toEqual([ 259 | ["M", 0, 0], 260 | ["S", 1, 2, 3, 4], 261 | ]); 262 | }); 263 | 264 | test("s", () => { 265 | path = "M0 0 s 1 2 3 4"; 266 | ary = parsePath(path); 267 | expect(ary).toEqual([ 268 | ["M", 0, 0], 269 | ["s", 1, 2, 3, 4], 270 | ]); 271 | }); 272 | 273 | test("C - exceed max parameters", () => { 274 | path = "M0 0 C 1 2 3 4 5 6 7"; 275 | ary = parsePath(path); 276 | expect(ary).toEqual([ 277 | ["M", 0, 0], 278 | ["C", 1, 2, 3, 4, 5, 6], 279 | ]); 280 | }); 281 | 282 | test("S - exceed max parameters", () => { 283 | path = "M0 0 S 1 2 3 4 5"; 284 | ary = parsePath(path); 285 | expect(ary).toEqual([ 286 | ["M", 0, 0], 287 | ["S", 1, 2, 3, 4], 288 | ]); 289 | }); 290 | 291 | test("C - below parameter limit", () => { 292 | path = "M0 0 C 1 2 3 4 5"; 293 | ary = parsePath(path); 294 | expect(ary).toEqual([["M", 0, 0]]); 295 | }); 296 | 297 | test("S - below parameter limit", () => { 298 | path = "M0 0 S 1 2 3"; 299 | ary = parsePath(path); 300 | expect(ary).toEqual([["M", 0, 0]]); 301 | }); 302 | }); 303 | 304 | describe("quadratic Bézier curve", () => { 305 | test("Q", () => { 306 | path = "M0 0 Q 1 2 3 4"; 307 | ary = parsePath(path); 308 | expect(ary).toEqual([ 309 | ["M", 0, 0], 310 | ["Q", 1, 2, 3, 4], 311 | ]); 312 | }); 313 | 314 | test("q", () => { 315 | path = "M0 0 q 1 2 3 4"; 316 | ary = parsePath(path); 317 | expect(ary).toEqual([ 318 | ["M", 0, 0], 319 | ["q", 1, 2, 3, 4], 320 | ]); 321 | }); 322 | 323 | test("T", () => { 324 | path = "M0 0 T 1 2"; 325 | ary = parsePath(path); 326 | expect(ary).toEqual([ 327 | ["M", 0, 0], 328 | ["T", 1, 2], 329 | ]); 330 | }); 331 | 332 | test("t", () => { 333 | path = "M0 0 t 1 2"; 334 | ary = parsePath(path); 335 | expect(ary).toEqual([ 336 | ["M", 0, 0], 337 | ["t", 1, 2], 338 | ]); 339 | }); 340 | 341 | test("Q - exceed max parameters", () => { 342 | path = "M0 0 Q 1 2 3 4 5"; 343 | ary = parsePath(path); 344 | expect(ary).toEqual([ 345 | ["M", 0, 0], 346 | ["Q", 1, 2, 3, 4], 347 | ]); 348 | }); 349 | 350 | test("T - exceed max parameters", () => { 351 | path = "M0 0 T 1 2 3"; 352 | ary = parsePath(path); 353 | expect(ary).toEqual([ 354 | ["M", 0, 0], 355 | ["T", 1, 2], 356 | ]); 357 | }); 358 | 359 | test("Q - below parameter limit", () => { 360 | path = "M0 0 Q 1 2 3"; 361 | ary = parsePath(path); 362 | expect(ary).toEqual([["M", 0, 0]]); 363 | }); 364 | 365 | test("T - below parameter limit", () => { 366 | path = "M0 0 T 1"; 367 | ary = parsePath(path); 368 | expect(ary).toEqual([["M", 0, 0]]); 369 | }); 370 | }); 371 | 372 | describe("elliptical arc curve", () => { 373 | test("A", () => { 374 | path = "M0 0 A 1 2 3 4 5 6 7"; 375 | ary = parsePath(path); 376 | expect(ary).toEqual([ 377 | ["M", 0, 0], 378 | ["A", 1, 2, 3, 4, 5, 6, 7], 379 | ]); 380 | }); 381 | 382 | test("a", () => { 383 | path = "M0 0 a 1 2 3 4 5 6 7"; 384 | ary = parsePath(path); 385 | expect(ary).toEqual([ 386 | ["M", 0, 0], 387 | ["a", 1, 2, 3, 4, 5, 6, 7], 388 | ]); 389 | }); 390 | 391 | test("exceed max parameters", () => { 392 | path = "M0 0 A 1 2 3 4 5 6 7 8"; 393 | ary = parsePath(path); 394 | expect(ary).toEqual([ 395 | ["M", 0, 0], 396 | ["A", 1, 2, 3, 4, 5, 6, 7], 397 | ]); 398 | }); 399 | 400 | test("below parameter limit", () => { 401 | path = "M0 0 A 1 2 3 4 5 6"; 402 | ary = parsePath(path); 403 | expect(ary).toEqual([["M", 0, 0]]); 404 | }); 405 | }); 406 | }); 407 | -------------------------------------------------------------------------------- /packages/path2d/src/__test__/path2d.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; 2 | import { applyPath2DToCanvasRenderingContext } from "../apply.js"; 3 | import { Path2D } from "../path2d.js"; 4 | import type { CanvasFillRule } from "../types.js"; 5 | import { CanvasRenderingContext2DForTest } from "./test-types.js"; 6 | 7 | vi.mock("./test-types.js"); 8 | 9 | type MockedClip = MockInstance<(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) => void>; 10 | type MockedFill = MockInstance<(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) => void>; 11 | type MockedStroke = MockInstance<(a?: CanvasFillRule | Path2D) => void>; 12 | type MockedIsPointInPath = MockInstance<() => string | number>; 13 | 14 | let ctx: CanvasRenderingContext2DForTest; 15 | let cStrokeSpy: MockedStroke; 16 | let cFillSpy: MockedFill; 17 | let cClipSpy: MockedClip; 18 | let cIsPointInPathSpy: MockedIsPointInPath; 19 | 20 | describe("Path2D", () => { 21 | beforeAll(() => { 22 | // prep some for testing, create spies on original CanvasRenderingContext methods 23 | cStrokeSpy = CanvasRenderingContext2DForTest.prototype.stroke as unknown as MockedStroke; 24 | cFillSpy = CanvasRenderingContext2DForTest.prototype.fill as unknown as MockedFill; 25 | cClipSpy = CanvasRenderingContext2DForTest.prototype.clip as unknown as MockedClip; 26 | cIsPointInPathSpy = CanvasRenderingContext2DForTest.prototype.isPointInPath as unknown as MockedIsPointInPath; 27 | applyPath2DToCanvasRenderingContext(CanvasRenderingContext2DForTest); 28 | }); 29 | beforeEach(() => { 30 | ctx = new CanvasRenderingContext2DForTest(); 31 | cStrokeSpy.mockReset(); 32 | cFillSpy.mockReset(); 33 | cClipSpy.mockReset(); 34 | cIsPointInPathSpy.mockReset(); 35 | }); 36 | 37 | describe("stroke/fill/clip", () => { 38 | it("clip - no arguments, defaults to nonzero", () => { 39 | ctx.clip(); 40 | expect(cClipSpy).toHaveBeenCalledOnce(); 41 | expect(cClipSpy).toHaveBeenCalledWith("nonzero"); 42 | }); 43 | 44 | it("clip - with fillrule", () => { 45 | ctx.clip("evenodd"); 46 | expect(cClipSpy).toHaveBeenCalledOnce(); 47 | expect(cClipSpy).toHaveBeenCalledWith("evenodd"); 48 | }); 49 | 50 | it("clip - with just path defaults to nonzero", () => { 51 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 52 | ctx.clip(p); 53 | expect(cClipSpy).toHaveBeenCalledOnce(); 54 | expect(cClipSpy).toHaveBeenCalledWith("nonzero"); 55 | }); 56 | 57 | it("clip - with path and fillrule", () => { 58 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 59 | ctx.clip(p, "evenodd"); 60 | expect(cClipSpy).toHaveBeenCalledOnce(); 61 | expect(cClipSpy).toHaveBeenCalledWith("evenodd"); 62 | }); 63 | 64 | it("fill - no arguments, defaults to nonzero", () => { 65 | ctx.fill(); 66 | expect(cFillSpy).toHaveBeenCalledOnce(); 67 | expect(cFillSpy).toHaveBeenCalledWith("nonzero"); 68 | }); 69 | 70 | it("fill - with fillrule", () => { 71 | ctx.fill("evenodd"); 72 | expect(cFillSpy).toHaveBeenCalledOnce(); 73 | expect(cFillSpy).toHaveBeenCalledWith("evenodd"); 74 | }); 75 | 76 | it("fill - with just path defaults to nonzero", () => { 77 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 78 | ctx.fill(p); 79 | expect(cFillSpy).toHaveBeenCalledOnce(); 80 | expect(cFillSpy).toHaveBeenCalledWith("nonzero"); 81 | }); 82 | 83 | it("fill - with path and fillrule", () => { 84 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 85 | ctx.fill(p, "evenodd"); 86 | expect(cFillSpy).toHaveBeenCalledOnce(); 87 | expect(cFillSpy).toHaveBeenCalledWith("evenodd"); 88 | }); 89 | 90 | it("stroke - no arguments", () => { 91 | ctx.stroke(); 92 | expect(cStrokeSpy).toHaveBeenCalledOnce(); 93 | expect(cStrokeSpy).toHaveBeenCalledWith(); 94 | }); 95 | 96 | it("should throw error when an arc command has been added without a starting point", () => { 97 | const p = new Path2D(); 98 | p.commands.push(["A", 45, 45, 0, 0, 0]); 99 | expect(() => ctx.stroke(p)).toThrowError("This should never happen"); 100 | }); 101 | }); 102 | 103 | describe("isPointInPath", () => { 104 | it("isPointInPath - with two arguments x, y", () => { 105 | const x = 20; 106 | const y = 30; 107 | ctx.isPointInPath(x, y); 108 | expect(cIsPointInPathSpy).toHaveBeenCalledOnce(); 109 | expect(cIsPointInPathSpy).toHaveBeenCalledWith(x, y); 110 | }); 111 | 112 | it("isPointInPath - with three arguments x, y, fillRule", () => { 113 | const x = 20; 114 | const y = 30; 115 | const fillRule = "nonzero"; 116 | ctx.isPointInPath(x, y, fillRule); 117 | expect(cIsPointInPathSpy).toHaveBeenCalledOnce(); 118 | expect(cIsPointInPathSpy).toHaveBeenCalledWith(x, y, fillRule); 119 | }); 120 | 121 | it("isPointInPath - with Path2D as first argument", () => { 122 | const region = new Path2D("M 30 90 L 110 20 L 240 130 L 60 130 L 190 20 L 270 90 Z"); 123 | const x = 30; 124 | const y = 20; 125 | ctx.isPointInPath(region, x, y); 126 | expect(ctx.moveTo).toHaveBeenCalledWith(30, 90); 127 | expect(ctx.lineTo).toHaveBeenCalledTimes(5); 128 | expect(ctx.closePath).toHaveBeenCalledOnce(); 129 | expect(cIsPointInPathSpy).toHaveBeenCalledWith(x, y, "nonzero"); 130 | }); 131 | 132 | it("isPointInPath - with Path2D as first argument and fillRule", () => { 133 | const region = new Path2D("M 30 90 L 110 20 L 240 130 L 60 130 L 190 20 L 270 90 Z"); 134 | const x = 30; 135 | const y = 20; 136 | const fillRule = "evenodd"; 137 | ctx.isPointInPath(region, x, y, fillRule); 138 | expect(ctx.moveTo).toHaveBeenCalledWith(30, 90); 139 | expect(ctx.lineTo).toHaveBeenCalledTimes(5); 140 | expect(ctx.closePath).toHaveBeenCalledOnce(); 141 | expect(cIsPointInPathSpy).toHaveBeenCalledWith(x, y, fillRule); 142 | }); 143 | }); 144 | 145 | describe("render path", () => { 146 | describe("with Path2D methods", () => { 147 | it("constructor with another Path2D object", () => { 148 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 149 | const p2 = new Path2D(p); 150 | ctx.stroke(p2); 151 | expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 10, 0); 152 | expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 10, 10); 153 | expect(ctx.closePath).toHaveBeenCalledOnce(); 154 | }); 155 | it("addPath", () => { 156 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 157 | const p2 = new Path2D(); 158 | p2.addPath(p); 159 | ctx.stroke(p2); 160 | expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 10, 0); 161 | expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 10, 10); 162 | expect(ctx.closePath).toHaveBeenCalledOnce(); 163 | }); 164 | it("addPath with falsy parameter", () => { 165 | const p = new Path2D("M 0 0 L 10 0 L 10 10 Z"); 166 | const p2 = "this is not a Path2D object"; 167 | // @ts-expect-error testing a falsy parameter 168 | p.addPath(p2); 169 | ctx.stroke(p); 170 | expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 10, 0); 171 | expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 10, 10); 172 | expect(ctx.closePath).toHaveBeenCalledOnce(); 173 | }); 174 | it("moveTo", () => { 175 | const p = new Path2D(); 176 | p.moveTo(10, 10); 177 | ctx.stroke(p); 178 | expect(ctx.moveTo).toHaveBeenCalledWith(10, 10); 179 | }); 180 | it("lineTo and closePath", () => { 181 | const p = new Path2D(); 182 | p.lineTo(10, 0); 183 | p.lineTo(10, 10); 184 | p.closePath(); 185 | ctx.stroke(p); 186 | expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 10, 0); 187 | expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 10, 10); 188 | expect(ctx.closePath).toHaveBeenCalledOnce(); 189 | }); 190 | it("bezierCurveTo", () => { 191 | const p = new Path2D(); 192 | p.bezierCurveTo(10, 10, 20, 20, 30, 30); 193 | ctx.stroke(p); 194 | expect(ctx.bezierCurveTo).toHaveBeenCalledWith(10, 10, 20, 20, 30, 30); 195 | }); 196 | it("quadraticCurveTo", () => { 197 | const p = new Path2D(); 198 | p.quadraticCurveTo(20, 20, 30, 30); 199 | ctx.stroke(p); 200 | expect(ctx.quadraticCurveTo).toHaveBeenCalledWith(20, 20, 30, 30); 201 | }); 202 | it("arc", () => { 203 | const p = new Path2D(); 204 | p.arc(20, 20, 30, 0, 40, true); 205 | ctx.stroke(p); 206 | expect(ctx.arc).toHaveBeenCalledWith(20, 20, 30, 0, 40, true); 207 | }); 208 | it("arcTo", () => { 209 | const p = new Path2D(); 210 | p.arcTo(20, 20, 30, 30, 40); 211 | ctx.stroke(p); 212 | expect(ctx.arcTo).toHaveBeenCalledWith(20, 20, 30, 30, 40); 213 | }); 214 | it("ellipse", () => { 215 | const p = new Path2D(); 216 | p.ellipse(100, 150, 20, 40, 0, 0, Math.PI, true); 217 | ctx.stroke(p); 218 | expect(ctx.translate).toHaveBeenCalledWith(100, 150); 219 | expect(ctx.scale).toHaveBeenCalledWith(20, 40); 220 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, 0, Math.PI, true); 221 | }); 222 | it("rect", () => { 223 | const p = new Path2D(); 224 | p.rect(20, 20, 30, 30); 225 | ctx.stroke(p); 226 | expect(ctx.rect).toHaveBeenCalledWith(20, 20, 30, 30); 227 | }); 228 | it("roundRect", () => { 229 | const p = new Path2D(); 230 | p.roundRect(20, 20, 30, 30, 10); 231 | ctx.stroke(p); 232 | expect(ctx.roundRect).toHaveBeenCalledWith(20, 20, 30, 30, 10); 233 | }); 234 | it("roundRect2", () => { 235 | const p = new Path2D(); 236 | p.roundRect(20, 20, 30, 30); 237 | ctx.stroke(p); 238 | expect(ctx.roundRect).toHaveBeenCalledWith(20, 20, 30, 30, 0); 239 | }); 240 | }); 241 | describe("moveTo", () => { 242 | it("M/m", () => { 243 | ctx.stroke(new Path2D("M 10 10 m 5 5 m -5 5")); 244 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 10, 10); 245 | expect(ctx.moveTo).toHaveBeenNthCalledWith(2, 15, 15); 246 | expect(ctx.moveTo).toHaveBeenNthCalledWith(3, 10, 20); 247 | }); 248 | }); 249 | describe("lineTo", () => { 250 | it("L", () => { 251 | ctx.stroke(new Path2D("M 10 10 L 20 20")); 252 | expect(ctx.moveTo).toHaveBeenCalledWith(10, 10); 253 | expect(ctx.lineTo).toHaveBeenCalledWith(20, 20); 254 | }); 255 | it("l", () => { 256 | ctx.stroke(new Path2D("M 10 10 l 5 5 l -5 5")); 257 | expect(ctx.moveTo).toHaveBeenCalledWith(10, 10); 258 | expect(ctx.lineTo).toHaveBeenNthCalledWith(1, 15, 15); 259 | expect(ctx.lineTo).toHaveBeenNthCalledWith(2, 10, 20); 260 | }); 261 | it("H", () => { 262 | ctx.stroke(new Path2D("M 10 0 H 20")); 263 | expect(ctx.lineTo).toHaveBeenCalledWith(20, 0); 264 | }); 265 | it("h", () => { 266 | ctx.stroke(new Path2D("M 10 0 h 10")); 267 | expect(ctx.lineTo).toHaveBeenCalledWith(20, 0); 268 | }); 269 | it("V", () => { 270 | ctx.stroke(new Path2D("M 0 10 V 20")); 271 | expect(ctx.lineTo).toHaveBeenCalledWith(0, 20); 272 | }); 273 | it("v", () => { 274 | ctx.stroke(new Path2D("M 0 10 v 10")); 275 | expect(ctx.lineTo).toHaveBeenCalledWith(0, 20); 276 | }); 277 | it("multiple l & m", () => { 278 | ctx.stroke(new Path2D("M 10 10 l 20 5 m 5 10 l -20 -10")); 279 | expect(ctx.moveTo).toHaveBeenCalledTimes(2); 280 | expect(ctx.lineTo).toHaveBeenCalledTimes(2); 281 | }); 282 | }); 283 | describe("arc", () => { 284 | it("A", () => { 285 | ctx.stroke(new Path2D("M80 80A 45 45 0 0 0 125 125L 125 80 Z")); 286 | expect(ctx.moveTo).toHaveBeenCalledWith(80, 80); 287 | expect(ctx.translate).toHaveBeenCalledWith(125, 80); 288 | expect(ctx.scale).toHaveBeenCalledWith(45, 45); 289 | expect(ctx.rotate).toHaveBeenCalledWith(0); 290 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, Math.PI, Math.PI / 2, true); 291 | expect(ctx.lineTo).toHaveBeenCalledWith(125, 80); 292 | expect(ctx.closePath).toHaveBeenCalledOnce(); 293 | }); 294 | it("a - with correction", () => { 295 | ctx.stroke(new Path2D("M40 0a 1 1 0 0 0 0 40L 0 20 Z")); 296 | expect(ctx.translate).toHaveBeenCalledWith(40, 20); 297 | expect(ctx.scale).toHaveBeenCalledWith(20, 20); 298 | expect(ctx.rotate).toHaveBeenCalledWith(0); 299 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, -Math.PI / 2, Math.PI / 2, true); 300 | }); 301 | it("a - with sweep flag arc", () => { 302 | ctx.stroke(new Path2D("M230 230a 45 45 0 1 0 45 45L 275 230 Z")); 303 | expect(ctx.translate).toHaveBeenCalledWith(230, 275); 304 | expect(ctx.scale).toHaveBeenCalledWith(45, 45); 305 | expect(ctx.rotate).toHaveBeenCalledWith(0); 306 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, -Math.PI / 2, -0, true); 307 | }); 308 | it("a - with sweep flag and large flag arc", () => { 309 | ctx.stroke(new Path2D("M230 230a 45 45 -45 1 1 45 45L 275 230 Z")); 310 | expect(ctx.translate).toHaveBeenCalledWith(275, 230); 311 | expect(ctx.scale).toHaveBeenCalledWith(45, 45); 312 | expect(ctx.rotate).toHaveBeenCalledWith(-Math.PI / 4); 313 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, (-3 * Math.PI) / 4, (3 * Math.PI) / 4, false); 314 | }); 315 | it("a - elliptical", () => { 316 | ctx.stroke(new Path2D("M230 230a 20 40 0 1 1 40 0L 275 230 Z")); 317 | expect(ctx.translate).toHaveBeenCalledWith(250, 230); 318 | expect(ctx.scale).toHaveBeenCalledWith(20, 40); 319 | expect(ctx.arc).toHaveBeenCalledWith(0, 0, 1, Math.PI, -0, false); 320 | }); 321 | }); 322 | describe("closePath", () => { 323 | it("Z and stroke", () => { 324 | ctx.stroke(new Path2D("M 16 90 L13 37 L3 14 Z")); 325 | expect(ctx.closePath).toHaveBeenCalledOnce(); 326 | }); 327 | it("z and fill", () => { 328 | ctx.fill(new Path2D("M 16 90 L13 37 L3 14 z")); 329 | expect(ctx.closePath).toHaveBeenCalledOnce(); 330 | }); 331 | }); 332 | describe("cubic bezier curve", () => { 333 | it("C", () => { 334 | ctx.stroke(new Path2D("M0 0, C1 2, 3 4, 5 6")); 335 | expect(ctx.bezierCurveTo).toHaveBeenCalledWith(1, 2, 3, 4, 5, 6); 336 | }); 337 | it("c", () => { 338 | ctx.stroke(new Path2D("M10 100, c1 2, 3 4, 5 6")); 339 | expect(ctx.bezierCurveTo).toHaveBeenCalledWith(11, 102, 13, 104, 15, 106); 340 | }); 341 | it("S - with previous cubic command", () => { 342 | ctx.stroke(new Path2D("M0 0, C1 2, 3 4, 5 6, S10 20, 30 40")); 343 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4, 5, 6); 344 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(2, 7, 8, 10, 20, 30, 40); 345 | }); 346 | it("S - without previous cubic command", () => { 347 | ctx.stroke(new Path2D("M1 2 S3 4, 5 6")); 348 | expect(ctx.bezierCurveTo).toHaveBeenCalledWith(1, 2, 3, 4, 5, 6); 349 | }); 350 | it("S - with break between cubic command", () => { 351 | ctx.stroke(new Path2D("M0 0, C1 2, 3 4, 5 6, M10 12, S10 20, 30 40")); 352 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4, 5, 6); 353 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(2, 10, 12, 10, 20, 30, 40); 354 | }); 355 | it("s - with previous cubic command", () => { 356 | ctx.stroke(new Path2D("M0 0, C1 2 3 4 5 6, s10 20 30 40")); 357 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4, 5, 6); 358 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(2, 7, 8, 15, 26, 35, 46); 359 | }); 360 | it("s - without previous cubic command", () => { 361 | ctx.stroke(new Path2D("M1 2, s10 20 30 40")); 362 | expect(ctx.bezierCurveTo).toHaveBeenCalledWith(1, 2, 11, 22, 31, 42); 363 | }); 364 | it("s - with break between cubic command", () => { 365 | ctx.stroke(new Path2D("M0 0, C1 2 3 4 5 6, M10 12, s10 20 30 40")); 366 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4, 5, 6); 367 | expect(ctx.bezierCurveTo).toHaveBeenNthCalledWith(2, 10, 12, 20, 32, 40, 52); 368 | }); 369 | }); 370 | describe("quad bezier curve", () => { 371 | it("Q", () => { 372 | ctx.stroke(new Path2D("M0 0, Q1 2, 3 4")); 373 | expect(ctx.quadraticCurveTo).toHaveBeenCalledWith(1, 2, 3, 4); 374 | }); 375 | it("q", () => { 376 | ctx.stroke(new Path2D("M10 100, q1 2, 3 4")); 377 | expect(ctx.quadraticCurveTo).toHaveBeenCalledWith(11, 102, 13, 104); 378 | }); 379 | it("T - with previous quad command", () => { 380 | ctx.stroke(new Path2D("M10 10, Q1 2,3 4 T10 100")); 381 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4); 382 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(2, 5, 6, 10, 100); 383 | }); 384 | it("T - without previous quad command", () => { 385 | ctx.stroke(new Path2D("M10 100, T1 2")); 386 | expect(ctx.quadraticCurveTo).toHaveBeenCalledWith(10, 100, 1, 2); 387 | }); 388 | it("T - with break between quad command", () => { 389 | ctx.stroke(new Path2D("M10 10, Q1 2,3 4, M10 12, T10 100")); 390 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(1, 1, 2, 3, 4); 391 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(2, 10, 12, 10, 100); 392 | }); 393 | it("t - with previous quad command", () => { 394 | ctx.stroke(new Path2D("M0 0, Q0 5, 10 15 t1 2")); 395 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(1, 0, 5, 10, 15); 396 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(2, 20, 25, 11, 17); 397 | }); 398 | it("t - without previous quad command", () => { 399 | ctx.stroke(new Path2D("M10 100, t1 2")); 400 | expect(ctx.quadraticCurveTo).toHaveBeenCalledWith(10, 100, 11, 102); 401 | }); 402 | it("t - with break between quad command", () => { 403 | ctx.stroke(new Path2D("M0 0, Q0 5, 10 15, M10 12, t1 2")); 404 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(1, 0, 5, 10, 15); 405 | expect(ctx.quadraticCurveTo).toHaveBeenNthCalledWith(2, 10, 12, 11, 14); 406 | }); 407 | }); 408 | }); 409 | }); 410 | -------------------------------------------------------------------------------- /packages/path2d/src/__test__/round-rect.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { 3 | applyPath2DToCanvasRenderingContext, 4 | applyRoundRectToCanvasRenderingContext2D, 5 | applyRoundRectToPath2D, 6 | } from "../apply.js"; 7 | import { Path2D } from "../path2d.js"; 8 | import { CanvasRenderingContext2DForTestWithoutRoundRect } from "./test-types.js"; 9 | 10 | vi.mock("./test-types.js"); 11 | 12 | let ctx: CanvasRenderingContext2DForTestWithoutRoundRect; 13 | 14 | describe("roundRect", () => { 15 | beforeAll(() => { 16 | // prep some for testing 17 | // @ts-expect-error testing purpose 18 | Path2D.prototype.roundRect = undefined; 19 | applyRoundRectToCanvasRenderingContext2D(CanvasRenderingContext2DForTestWithoutRoundRect); 20 | applyRoundRectToPath2D(Path2D); 21 | // @ts-expect-error roundRect has been polyffilled 22 | applyPath2DToCanvasRenderingContext(CanvasRenderingContext2DForTestWithoutRoundRect); 23 | }); 24 | beforeEach(() => { 25 | ctx = new CanvasRenderingContext2DForTestWithoutRoundRect(); 26 | }); 27 | 28 | it("with no radius", () => { 29 | const p = new Path2D(); 30 | p.roundRect(20, 20, 30, 30); 31 | ctx.stroke(p); 32 | expect(ctx.rect).toHaveBeenCalledWith(20, 20, 30, 30); 33 | }); 34 | 35 | it("throw error with negative radius", () => { 36 | const p = new Path2D(); 37 | expect(() => p.roundRect(20, 20, 30, 30, -10)).toThrowError( 38 | "Failed to execute 'roundRect' on 'Path2D': Radius value -10 is negative", 39 | ); 40 | expect(() => p.roundRect(20, 20, 30, 30, [-10])).toThrowError( 41 | "Failed to execute 'roundRect' on 'Path2D': Radius value -10 is negative", 42 | ); 43 | expect(() => p.roundRect(20, 20, 30, 30, [10, -10])).toThrowError( 44 | "Failed to execute 'roundRect' on 'Path2D': Radius value -10 is negative", 45 | ); 46 | }); 47 | 48 | it("throw error when wrong amount of parameters", () => { 49 | const p = new Path2D(); 50 | expect(() => p.roundRect(20, 20, 30, 30, [])).toThrowError( 51 | "Failed to execute 'roundRect' on 'Path2D': 0 radii provided. Between one and four radii are necessary", 52 | ); 53 | expect(() => p.roundRect(20, 20, 30, 30, [1, 2, 3, 4, 5])).toThrowError( 54 | "Failed to execute 'roundRect' on 'Path2D': 5 radii provided. Between one and four radii are necessary", 55 | ); 56 | }); 57 | 58 | it("with wrong radius type", () => { 59 | const p = new Path2D(); 60 | // @ts-expect-error testing wrong parameter type 61 | p.roundRect(0, 0, 10, 10, "string"); 62 | ctx.stroke(p); 63 | expect(ctx.moveTo).not.toHaveBeenCalled(); 64 | expect(ctx.arcTo).not.toHaveBeenCalled(); 65 | expect(ctx.closePath).not.toHaveBeenCalled(); 66 | }); 67 | 68 | it("with one radius", () => { 69 | const p = new Path2D(); 70 | p.roundRect(0, 0, 10, 10, 1); 71 | ctx.stroke(p); 72 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 0, 9); 73 | expect(ctx.arcTo).toHaveBeenNthCalledWith(1, 0, 0, 1, 0, 1); 74 | expect(ctx.arcTo).toHaveBeenNthCalledWith(2, 10, 0, 10, 1, 1); 75 | expect(ctx.arcTo).toHaveBeenNthCalledWith(3, 10, 10, 9, 10, 1); 76 | expect(ctx.arcTo).toHaveBeenNthCalledWith(4, 0, 10, 0, 9, 1); 77 | expect(ctx.closePath).toHaveBeenCalled(); 78 | }); 79 | 80 | it("with one radius - as array", () => { 81 | const p = new Path2D(); 82 | p.roundRect(0, 0, 10, 10, [1]); 83 | ctx.stroke(p); 84 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 0, 9); 85 | expect(ctx.arcTo).toHaveBeenNthCalledWith(1, 0, 0, 1, 0, 1); 86 | expect(ctx.arcTo).toHaveBeenNthCalledWith(2, 10, 0, 10, 1, 1); 87 | expect(ctx.arcTo).toHaveBeenNthCalledWith(3, 10, 10, 9, 10, 1); 88 | expect(ctx.arcTo).toHaveBeenNthCalledWith(4, 0, 10, 0, 9, 1); 89 | expect(ctx.closePath).toHaveBeenCalled(); 90 | }); 91 | 92 | it("with two radiuses", () => { 93 | const p = new Path2D(); 94 | p.roundRect(0, 0, 10, 10, [1, 2]); 95 | ctx.stroke(p); 96 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 0, 8); 97 | expect(ctx.arcTo).toHaveBeenNthCalledWith(1, 0, 0, 1, 0, 1); 98 | expect(ctx.arcTo).toHaveBeenNthCalledWith(2, 10, 0, 10, 2, 2); 99 | expect(ctx.arcTo).toHaveBeenNthCalledWith(3, 10, 10, 9, 10, 1); 100 | expect(ctx.arcTo).toHaveBeenNthCalledWith(4, 0, 10, 0, 8, 2); 101 | expect(ctx.closePath).toHaveBeenCalled(); 102 | }); 103 | 104 | it("with three radiuses", () => { 105 | const p = new Path2D(); 106 | p.roundRect(0, 0, 10, 10, [1, 2, 3]); 107 | ctx.stroke(p); 108 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 0, 8); 109 | expect(ctx.arcTo).toHaveBeenNthCalledWith(1, 0, 0, 1, 0, 1); 110 | expect(ctx.arcTo).toHaveBeenNthCalledWith(2, 10, 0, 10, 2, 2); 111 | expect(ctx.arcTo).toHaveBeenNthCalledWith(3, 10, 10, 7, 10, 3); 112 | expect(ctx.arcTo).toHaveBeenNthCalledWith(4, 0, 10, 0, 8, 2); 113 | expect(ctx.closePath).toHaveBeenCalled(); 114 | }); 115 | 116 | it("with four radiuses", () => { 117 | const p = new Path2D(); 118 | p.roundRect(0, 0, 10, 10, [1, 2, 3, 4]); 119 | ctx.stroke(p); 120 | expect(ctx.moveTo).toHaveBeenNthCalledWith(1, 0, 6); 121 | expect(ctx.arcTo).toHaveBeenNthCalledWith(1, 0, 0, 1, 0, 1); 122 | expect(ctx.arcTo).toHaveBeenNthCalledWith(2, 10, 0, 10, 2, 2); 123 | expect(ctx.arcTo).toHaveBeenNthCalledWith(3, 10, 10, 7, 10, 3); 124 | expect(ctx.arcTo).toHaveBeenNthCalledWith(4, 0, 10, 0, 6, 4); 125 | expect(ctx.closePath).toHaveBeenCalled(); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /packages/path2d/src/__test__/test-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { type Path2D } from "../path2d.js"; 4 | import { type CanvasFillRule, type ICanvasRenderingContext2D, type IPath2D } from "../types.js"; 5 | 6 | export class CanvasRenderingContext2DForTest implements ICanvasRenderingContext2D { 7 | strokeStyle: string | null; 8 | lineWidth: number | null; 9 | constructor() { 10 | this.strokeStyle = null; 11 | this.lineWidth = null; 12 | } 13 | clip(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 14 | fill(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 15 | stroke(a?: CanvasFillRule | Path2D) {} 16 | isPointInPath(a: number | Path2D, b: number, c?: number | CanvasFillRule, d?: number | CanvasFillRule) { 17 | return false; 18 | } 19 | beginPath() {} 20 | moveTo() {} 21 | lineTo() {} 22 | ellipse() {} 23 | arc() {} 24 | arcTo() {} 25 | closePath() {} 26 | bezierCurveTo() {} 27 | quadraticCurveTo() {} 28 | rect() {} 29 | roundRect() {} 30 | save() {} 31 | translate() {} 32 | rotate() {} 33 | scale() {} 34 | restore() {} 35 | } 36 | 37 | export class CanvasRenderingContext2DForTestWithoutRoundRect { 38 | strokeStyle: string | null; 39 | lineWidth: number | null; 40 | constructor() { 41 | this.strokeStyle = null; 42 | this.lineWidth = null; 43 | } 44 | clip(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 45 | fill(a?: CanvasFillRule | Path2D, b?: CanvasFillRule) {} 46 | stroke(a?: CanvasFillRule | Path2D) {} 47 | isPointInPath(a: number | Path2D, b: number, c?: number | CanvasFillRule, d?: number | CanvasFillRule) { 48 | return false; 49 | } 50 | beginPath() {} 51 | moveTo() {} 52 | lineTo() {} 53 | ellipse() {} 54 | arc() {} 55 | arcTo() {} 56 | closePath() {} 57 | bezierCurveTo() {} 58 | quadraticCurveTo() {} 59 | rect() {} 60 | save() {} 61 | translate() {} 62 | rotate() {} 63 | scale() {} 64 | restore() {} 65 | } 66 | 67 | export class Path2DForTest implements IPath2D { 68 | addPath() {} 69 | moveTo() {} 70 | lineTo() {} 71 | ellipse() {} 72 | arc() {} 73 | arcTo() {} 74 | closePath() {} 75 | bezierCurveTo() {} 76 | quadraticCurveTo() {} 77 | rect() {} 78 | roundRect() {} 79 | } 80 | 81 | export class Path2DForTestWithoutRoundRect { 82 | addPath() {} 83 | moveTo() {} 84 | lineTo() {} 85 | ellipse() {} 86 | arc() {} 87 | arcTo() {} 88 | closePath() {} 89 | bezierCurveTo() {} 90 | quadraticCurveTo() {} 91 | rect() {} 92 | } 93 | 94 | export interface Global { 95 | CanvasRenderingContext2D: typeof CanvasRenderingContext2DForTest; 96 | Path2D: typeof Path2D; 97 | } 98 | -------------------------------------------------------------------------------- /packages/path2d/src/apply.ts: -------------------------------------------------------------------------------- 1 | import { Path2D, buildPath } from "./path2d.js"; 2 | import { roundRect } from "./round-rect.js"; 3 | import type { 4 | CanvasFillRule, 5 | ICanvasRenderingContext2D, 6 | ICanvasRenderingContext2DWithoutPath2D, 7 | IPath2D, 8 | IPrototype, 9 | PartialBy, 10 | } from "./types.js"; 11 | 12 | type FillFn = (fillRule?: CanvasFillRule) => void; 13 | type StrokeFn = () => void; 14 | type IsPointInPathFn = (x: number, y: number, fillRule?: CanvasFillRule) => boolean; 15 | 16 | /** 17 | * Adds Path2D capabilities to CanvasRenderingContext2D stroke, fill and isPointInPath 18 | * @param global - window like object containing a CanvasRenderingContext2D constructor 19 | */ 20 | export function applyPath2DToCanvasRenderingContext( 21 | CanvasRenderingContext2D?: IPrototype, 22 | ) { 23 | if (!CanvasRenderingContext2D) return; 24 | 25 | /* eslint-disable @typescript-eslint/unbound-method */ 26 | // setting unbound functions here. Make sure this is set in function call later 27 | const cClip: FillFn = CanvasRenderingContext2D.prototype.clip; 28 | const cFill: FillFn = CanvasRenderingContext2D.prototype.fill; 29 | const cStroke: StrokeFn = CanvasRenderingContext2D.prototype.stroke; 30 | const cIsPointInPath: IsPointInPathFn = CanvasRenderingContext2D.prototype.isPointInPath; 31 | /* eslint-enable @typescript-eslint/unbound-method */ 32 | 33 | CanvasRenderingContext2D.prototype.clip = function clip(...args: unknown[]) { 34 | if (args[0] instanceof Path2D) { 35 | const path = args[0]; 36 | const fillRule = (args[1] as CanvasFillRule) || "nonzero"; 37 | buildPath(this as ICanvasRenderingContext2D, path.commands); 38 | return cClip.apply(this, [fillRule]); 39 | } 40 | const fillRule = (args[0] as CanvasFillRule) || "nonzero"; 41 | return cClip.apply(this, [fillRule]); 42 | }; 43 | 44 | CanvasRenderingContext2D.prototype.fill = function fill(...args: unknown[]) { 45 | if (args[0] instanceof Path2D) { 46 | const path = args[0]; 47 | const fillRule = (args[1] as CanvasFillRule) || "nonzero"; 48 | buildPath(this as ICanvasRenderingContext2D, path.commands); 49 | return cFill.apply(this, [fillRule]); 50 | } 51 | const fillRule = (args[0] as CanvasFillRule) || "nonzero"; 52 | return cFill.apply(this, [fillRule]); 53 | }; 54 | 55 | CanvasRenderingContext2D.prototype.stroke = function stroke(path?: Path2D) { 56 | if (path) { 57 | buildPath(this as ICanvasRenderingContext2D, path.commands); 58 | } 59 | cStroke.apply(this); 60 | }; 61 | 62 | CanvasRenderingContext2D.prototype.isPointInPath = function isPointInPath(...args: unknown[]) { 63 | if (args[0] instanceof Path2D) { 64 | // first argument is a Path2D object 65 | const path = args[0]; 66 | const x = args[1] as number; 67 | const y = args[2] as number; 68 | const fillRule = (args[3] as CanvasFillRule) || "nonzero"; 69 | buildPath(this as ICanvasRenderingContext2D, path.commands); 70 | return cIsPointInPath.apply(this, [x, y, fillRule]); 71 | } 72 | return cIsPointInPath.apply(this, args as [x: number, y: number, fillRule: CanvasFillRule]); 73 | }; 74 | } 75 | 76 | /** 77 | * Polyfills roundRect on CanvasRenderingContext2D 78 | * @param CanvasRenderingContext2D - CanvasRenderingContext2D constructor object 79 | */ 80 | export function applyRoundRectToCanvasRenderingContext2D( 81 | CanvasRenderingContext2D?: IPrototype>, 82 | ) { 83 | // polyfill unsupported roundRect for e.g. firefox https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect#browser_compatibility 84 | if (CanvasRenderingContext2D && !CanvasRenderingContext2D.prototype.roundRect) { 85 | CanvasRenderingContext2D.prototype.roundRect = roundRect; 86 | } 87 | } 88 | 89 | /** 90 | * Polyfills roundRect on Path2D 91 | * @param Path2D - Path2D constructor object 92 | */ 93 | export function applyRoundRectToPath2D(P2D?: IPrototype>) { 94 | // polyfill unsupported roundRect for e.g. firefox https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect#browser_compatibility 95 | if (P2D && !P2D.prototype.roundRect) { 96 | P2D.prototype.roundRect = roundRect; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/path2d/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./apply.js"; 2 | export * from "./parse-path.js"; 3 | export * from "./path2d.js"; 4 | export * from "./round-rect.js"; 5 | export * from "./types.js"; 6 | -------------------------------------------------------------------------------- /packages/path2d/src/parse-path.ts: -------------------------------------------------------------------------------- 1 | import type { Command, MovePathCommand, PathCommand } from "./types.js"; 2 | 3 | type ArgLengthProp = "a" | "c" | "h" | "l" | "m" | "q" | "s" | "t" | "v" | "z"; 4 | 5 | const ARG_LENGTH = { 6 | a: 7, 7 | c: 6, 8 | h: 1, 9 | l: 2, 10 | m: 2, 11 | q: 4, 12 | s: 4, 13 | t: 2, 14 | v: 1, 15 | z: 0, 16 | }; 17 | 18 | const SEGMENT_PATTERN = /([astvzqmhlc])([^astvzqmhlc]*)/gi; 19 | 20 | const NUMBER = /-?[0-9]*\.?[0-9]+(?:e[-+]?\d+)?/gi; 21 | 22 | function parseValues(args: string): number[] { 23 | const numbers = args.match(NUMBER); 24 | return numbers ? numbers.map(Number) : []; 25 | } 26 | 27 | /** 28 | * parse an svg path data string. Generates an Array 29 | * of commands where each command is an Array of the 30 | * form `[command, arg1, arg2, ...]` 31 | * 32 | * https://www.w3.org/TR/SVG/paths.html#PathDataGeneralInformation 33 | * @ignore 34 | * 35 | * @param {string} path 36 | * @returns {array} 37 | */ 38 | export function parsePath(path: string): PathCommand[] { 39 | const data: PathCommand[] = []; 40 | const p = String(path).trim(); 41 | 42 | // A path data segment (if there is one) must begin with a "moveto" command 43 | if (p[0] !== "M" && p[0] !== "m") { 44 | return data; 45 | } 46 | 47 | p.replace(SEGMENT_PATTERN, (_, command: string, args: string) => { 48 | const theArgs = parseValues(args); 49 | let type = command.toLowerCase() as ArgLengthProp; 50 | let theCommand = command as Command; 51 | // overloaded moveTo 52 | if (type === "m" && theArgs.length > 2) { 53 | data.push([theCommand, ...theArgs.splice(0, 2)] as MovePathCommand); 54 | type = "l"; 55 | theCommand = theCommand === "m" ? "l" : "L"; 56 | } 57 | 58 | // Ignore invalid commands 59 | if (theArgs.length < ARG_LENGTH[type]) { 60 | return ""; 61 | } 62 | 63 | data.push([theCommand, ...theArgs.splice(0, ARG_LENGTH[type])]); 64 | 65 | // The command letter can be eliminated on subsequent commands if the 66 | // same command is used multiple times in a row (e.g., you can drop the 67 | // second "L" in "M 100 200 L 200 100 L -100 -200" and use 68 | // "M 100 200 L 200 100 -100 -200" instead). 69 | while (theArgs.length >= ARG_LENGTH[type] && theArgs.length && ARG_LENGTH[type]) { 70 | data.push([theCommand, ...theArgs.splice(0, ARG_LENGTH[type])]); 71 | } 72 | 73 | return ""; 74 | }); 75 | return data; 76 | } 77 | -------------------------------------------------------------------------------- /packages/path2d/src/path2d.ts: -------------------------------------------------------------------------------- 1 | import { parsePath } from "./parse-path.js"; 2 | import type { 3 | ArcCommand, 4 | ArcPathCommand, 5 | ArcToCommand, 6 | Command, 7 | CurvePathCommand, 8 | EllipseCommand, 9 | HorizontalPathCommand, 10 | ICanvasRenderingContext2D, 11 | IPath2D, 12 | LinePathCommand, 13 | MovePathCommand, 14 | PathCommand, 15 | QuadraticCurvePathCommand, 16 | RectCommand, 17 | RoundRectCommand, 18 | ShortCurvePathCommand, 19 | ShortQuadraticCurvePathCommand, 20 | VerticalPathCommand, 21 | } from "./types.js"; 22 | 23 | type Point = { 24 | x: number; 25 | y: number; 26 | }; 27 | 28 | function rotatePoint(point: Point, angle: number) { 29 | const nx = point.x * Math.cos(angle) - point.y * Math.sin(angle); 30 | const ny = point.y * Math.cos(angle) + point.x * Math.sin(angle); 31 | point.x = nx; 32 | point.y = ny; 33 | } 34 | 35 | function translatePoint(point: Point, dx: number, dy: number) { 36 | point.x += dx; 37 | point.y += dy; 38 | } 39 | 40 | function scalePoint(point: Point, s: number) { 41 | point.x *= s; 42 | point.y *= s; 43 | } 44 | 45 | /** 46 | * Implements a browser's Path2D api 47 | * https://developer.mozilla.org/en-US/docs/Web/API/Path2D 48 | */ 49 | export class Path2D implements IPath2D { 50 | commands: PathCommand[]; 51 | 52 | constructor(path?: Path2D | string) { 53 | this.commands = []; 54 | if (path && path instanceof Path2D) { 55 | this.commands.push(...path.commands); 56 | } else if (path) { 57 | this.commands = parsePath(path); 58 | } 59 | } 60 | 61 | addPath(path: Path2D) { 62 | if (path && path instanceof Path2D) { 63 | this.commands.push(...path.commands); 64 | } 65 | } 66 | 67 | moveTo(x: number, y: number) { 68 | this.commands.push(["M", x, y]); 69 | } 70 | 71 | lineTo(x: number, y: number) { 72 | this.commands.push(["L", x, y]); 73 | } 74 | 75 | arc(x: number, y: number, r: number, start: number, end: number, ccw: boolean) { 76 | this.commands.push(["AC", x, y, r, start, end, !!ccw]); 77 | } 78 | 79 | arcTo(x1: number, y1: number, x2: number, y2: number, r: number) { 80 | this.commands.push(["AT", x1, y1, x2, y2, r]); 81 | } 82 | 83 | ellipse(x: number, y: number, rx: number, ry: number, angle: number, start: number, end: number, ccw: boolean) { 84 | this.commands.push(["E", x, y, rx, ry, angle, start, end, !!ccw]); 85 | } 86 | 87 | closePath() { 88 | this.commands.push(["Z"]); 89 | } 90 | 91 | bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number) { 92 | this.commands.push(["C", cp1x, cp1y, cp2x, cp2y, x, y]); 93 | } 94 | 95 | quadraticCurveTo(cpx: number, cpy: number, x: number, y: number) { 96 | this.commands.push(["Q", cpx, cpy, x, y]); 97 | } 98 | 99 | rect(x: number, y: number, width: number, height: number) { 100 | this.commands.push(["R", x, y, width, height]); 101 | } 102 | 103 | roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]) { 104 | if (typeof radii === "undefined") { 105 | this.commands.push(["RR", x, y, width, height, 0]); 106 | } else { 107 | this.commands.push(["RR", x, y, width, height, radii]); 108 | } 109 | } 110 | } 111 | 112 | export function buildPath(ctx: ICanvasRenderingContext2D, commands: PathCommand[]) { 113 | let x = 0; 114 | let y = 0; 115 | let endAngle: number; 116 | let startAngle: number; 117 | let largeArcFlag: boolean; 118 | let sweepFlag: boolean; 119 | let endPoint: Point; 120 | let midPoint: Point; 121 | let angle: number; 122 | let lambda: number; 123 | let t1: number; 124 | let t2: number; 125 | let x1: number; 126 | let y1: number; 127 | let r: number; 128 | let rx: number; 129 | let ry: number; 130 | let w: number; 131 | let h: number; 132 | let pathType: Command; 133 | let centerPoint: Point; 134 | let ccw: boolean; 135 | let radii: number | number[]; 136 | let cpx: null | number = null; 137 | let cpy: null | number = null; 138 | let qcpx: null | number = null; 139 | let qcpy: null | number = null; 140 | let startPoint: null | Point = null; 141 | let currentPoint: null | Point = null; 142 | 143 | ctx.beginPath(); 144 | for (let i = 0; i < commands.length; ++i) { 145 | pathType = commands[i][0]; 146 | 147 | // Reset control point if command is not cubic 148 | if (pathType !== "S" && pathType !== "s" && pathType !== "C" && pathType !== "c") { 149 | cpx = null; 150 | cpy = null; 151 | } 152 | 153 | if (pathType !== "T" && pathType !== "t" && pathType !== "Q" && pathType !== "q") { 154 | qcpx = null; 155 | qcpy = null; 156 | } 157 | let c; 158 | switch (pathType) { 159 | case "m": 160 | case "M": 161 | c = commands[i] as MovePathCommand; 162 | if (pathType === "m") { 163 | x += c[1]; 164 | y += c[2]; 165 | } else { 166 | x = c[1]; 167 | y = c[2]; 168 | } 169 | 170 | if (pathType === "M" || !startPoint) { 171 | startPoint = { x, y }; 172 | } 173 | 174 | ctx.moveTo(x, y); 175 | break; 176 | case "l": 177 | c = commands[i] as LinePathCommand; 178 | x += c[1]; 179 | y += c[2]; 180 | ctx.lineTo(x, y); 181 | break; 182 | case "L": 183 | c = commands[i] as LinePathCommand; 184 | x = c[1]; 185 | y = c[2]; 186 | ctx.lineTo(x, y); 187 | break; 188 | case "H": 189 | c = commands[i] as HorizontalPathCommand; 190 | x = c[1]; 191 | ctx.lineTo(x, y); 192 | break; 193 | case "h": 194 | c = commands[i] as HorizontalPathCommand; 195 | x += c[1]; 196 | ctx.lineTo(x, y); 197 | break; 198 | case "V": 199 | c = commands[i] as VerticalPathCommand; 200 | y = c[1]; 201 | ctx.lineTo(x, y); 202 | break; 203 | case "v": 204 | c = commands[i] as VerticalPathCommand; 205 | y += c[1]; 206 | ctx.lineTo(x, y); 207 | break; 208 | case "a": 209 | case "A": 210 | c = commands[i] as ArcPathCommand; 211 | if (currentPoint === null) { 212 | throw new Error("This should never happen"); 213 | } 214 | if (pathType === "a") { 215 | x += c[6]; 216 | y += c[7]; 217 | } else { 218 | x = c[6]; 219 | y = c[7]; 220 | } 221 | 222 | rx = c[1]; // rx 223 | ry = c[2]; // ry 224 | angle = (c[3] * Math.PI) / 180; 225 | largeArcFlag = !!c[4]; 226 | sweepFlag = !!c[5]; 227 | endPoint = { x, y }; 228 | 229 | // https://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes 230 | 231 | midPoint = { 232 | x: (currentPoint.x - endPoint.x) / 2, 233 | y: (currentPoint.y - endPoint.y) / 2, 234 | }; 235 | rotatePoint(midPoint, -angle); 236 | 237 | // radius correction 238 | lambda = (midPoint.x * midPoint.x) / (rx * rx) + (midPoint.y * midPoint.y) / (ry * ry); 239 | if (lambda > 1) { 240 | lambda = Math.sqrt(lambda); 241 | rx *= lambda; 242 | ry *= lambda; 243 | } 244 | 245 | centerPoint = { 246 | x: (rx * midPoint.y) / ry, 247 | y: -(ry * midPoint.x) / rx, 248 | }; 249 | t1 = rx * rx * ry * ry; 250 | t2 = rx * rx * midPoint.y * midPoint.y + ry * ry * midPoint.x * midPoint.x; 251 | if (sweepFlag !== largeArcFlag) { 252 | scalePoint(centerPoint, Math.sqrt((t1 - t2) / t2) || 0); 253 | } else { 254 | scalePoint(centerPoint, -Math.sqrt((t1 - t2) / t2) || 0); 255 | } 256 | 257 | startAngle = Math.atan2((midPoint.y - centerPoint.y) / ry, (midPoint.x - centerPoint.x) / rx); 258 | endAngle = Math.atan2(-(midPoint.y + centerPoint.y) / ry, -(midPoint.x + centerPoint.x) / rx); 259 | 260 | rotatePoint(centerPoint, angle); 261 | translatePoint(centerPoint, (endPoint.x + currentPoint.x) / 2, (endPoint.y + currentPoint.y) / 2); 262 | 263 | ctx.save(); 264 | ctx.translate(centerPoint.x, centerPoint.y); 265 | ctx.rotate(angle); 266 | ctx.scale(rx, ry); 267 | ctx.arc(0, 0, 1, startAngle, endAngle, !sweepFlag); 268 | ctx.restore(); 269 | break; 270 | case "C": 271 | c = commands[i] as CurvePathCommand; 272 | cpx = c[3]; // Last control point 273 | cpy = c[4]; 274 | x = c[5]; 275 | y = c[6]; 276 | ctx.bezierCurveTo(c[1], c[2], cpx, cpy, x, y); 277 | break; 278 | case "c": 279 | c = commands[i] as CurvePathCommand; 280 | ctx.bezierCurveTo(c[1] + x, c[2] + y, c[3] + x, c[4] + y, c[5] + x, c[6] + y); 281 | cpx = c[3] + x; // Last control point 282 | cpy = c[4] + y; 283 | x += c[5]; 284 | y += c[6]; 285 | break; 286 | case "S": 287 | c = commands[i] as ShortCurvePathCommand; 288 | if (cpx === null || cpy === null) { 289 | cpx = x; 290 | cpy = y; 291 | } 292 | 293 | ctx.bezierCurveTo(2 * x - cpx, 2 * y - cpy, c[1], c[2], c[3], c[4]); 294 | cpx = c[1]; // last control point 295 | cpy = c[2]; 296 | x = c[3]; 297 | y = c[4]; 298 | break; 299 | case "s": 300 | c = commands[i] as ShortCurvePathCommand; 301 | if (cpx === null || cpy === null) { 302 | cpx = x; 303 | cpy = y; 304 | } 305 | 306 | ctx.bezierCurveTo(2 * x - cpx, 2 * y - cpy, c[1] + x, c[2] + y, c[3] + x, c[4] + y); 307 | cpx = c[1] + x; // last control point 308 | cpy = c[2] + y; 309 | x += c[3]; 310 | y += c[4]; 311 | break; 312 | case "Q": 313 | c = commands[i] as QuadraticCurvePathCommand; 314 | qcpx = c[1]; // last control point 315 | qcpy = c[2]; 316 | x = c[3]; 317 | y = c[4]; 318 | ctx.quadraticCurveTo(qcpx, qcpy, x, y); 319 | break; 320 | case "q": 321 | c = commands[i] as QuadraticCurvePathCommand; 322 | qcpx = c[1] + x; // last control point 323 | qcpy = c[2] + y; 324 | x += c[3]; 325 | y += c[4]; 326 | ctx.quadraticCurveTo(qcpx, qcpy, x, y); 327 | break; 328 | case "T": 329 | c = commands[i] as ShortQuadraticCurvePathCommand; 330 | if (qcpx === null || qcpy === null) { 331 | qcpx = x; 332 | qcpy = y; 333 | } 334 | qcpx = 2 * x - qcpx; // last control point 335 | qcpy = 2 * y - qcpy; 336 | x = c[1]; 337 | y = c[2]; 338 | ctx.quadraticCurveTo(qcpx, qcpy, x, y); 339 | break; 340 | case "t": 341 | c = commands[i] as ShortQuadraticCurvePathCommand; 342 | if (qcpx === null || qcpy === null) { 343 | qcpx = x; 344 | qcpy = y; 345 | } 346 | qcpx = 2 * x - qcpx; // last control point 347 | qcpy = 2 * y - qcpy; 348 | x += c[1]; 349 | y += c[2]; 350 | ctx.quadraticCurveTo(qcpx, qcpy, x, y); 351 | break; 352 | case "z": 353 | case "Z": 354 | if (startPoint) { 355 | x = startPoint.x; 356 | y = startPoint.y; 357 | } 358 | startPoint = null; 359 | ctx.closePath(); 360 | break; 361 | case "AC": // arc 362 | c = commands[i] as ArcCommand; 363 | x = c[1]; 364 | y = c[2]; 365 | r = c[3]; 366 | startAngle = c[4]; 367 | endAngle = c[5]; 368 | ccw = c[6]; 369 | ctx.arc(x, y, r, startAngle, endAngle, ccw); 370 | break; 371 | case "AT": // arcTo 372 | c = commands[i] as ArcToCommand; 373 | x1 = c[1]; 374 | y1 = c[2]; 375 | x = c[3]; 376 | y = c[4]; 377 | r = c[5]; 378 | ctx.arcTo(x1, y1, x, y, r); 379 | break; 380 | case "E": // ellipse 381 | c = commands[i] as EllipseCommand; 382 | x = c[1]; 383 | y = c[2]; 384 | rx = c[3]; 385 | ry = c[4]; 386 | angle = c[5]; 387 | startAngle = c[6]; 388 | endAngle = c[7]; 389 | ccw = c[8]; 390 | ctx.save(); 391 | ctx.translate(x, y); 392 | ctx.rotate(angle); 393 | ctx.scale(rx, ry); 394 | ctx.arc(0, 0, 1, startAngle, endAngle, ccw); 395 | ctx.restore(); 396 | break; 397 | case "R": // rect 398 | c = commands[i] as RectCommand; 399 | x = c[1]; 400 | y = c[2]; 401 | w = c[3]; 402 | h = c[4]; 403 | startPoint = { x, y }; 404 | ctx.rect(x, y, w, h); 405 | break; 406 | case "RR": // roundedRect 407 | c = commands[i] as RoundRectCommand; 408 | x = c[1]; 409 | y = c[2]; 410 | w = c[3]; 411 | h = c[4]; 412 | radii = c[5]; 413 | startPoint = { x, y }; 414 | ctx.roundRect(x, y, w, h, radii); 415 | break; 416 | default: 417 | throw new Error(`Invalid path command: ${pathType as string}`); 418 | } 419 | 420 | if (!currentPoint) { 421 | currentPoint = { x, y }; 422 | } else { 423 | currentPoint.x = x; 424 | currentPoint.y = y; 425 | } 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /packages/path2d/src/round-rect.ts: -------------------------------------------------------------------------------- 1 | import { type ICanvasRenderingContext2D, type IPath2D } from "./types.js"; 2 | 3 | export function roundRect( 4 | this: IPath2D | ICanvasRenderingContext2D, 5 | x: number, 6 | y: number, 7 | width: number, 8 | height: number, 9 | radii: number | number[] = 0, 10 | ): void { 11 | if (typeof radii === "number") { 12 | radii = [radii]; 13 | } 14 | // check for range error 15 | if (Array.isArray(radii)) { 16 | if (radii.length === 0 || radii.length > 4) { 17 | throw new RangeError( 18 | `Failed to execute 'roundRect' on '${this.constructor.name}': ${radii.length} radii provided. Between one and four radii are necessary.`, 19 | ); 20 | } 21 | radii.forEach((v) => { 22 | if (v < 0) { 23 | throw new RangeError( 24 | `Failed to execute 'roundRect' on '${this.constructor.name}': Radius value ${v} is negative.`, 25 | ); 26 | } 27 | }); 28 | } else { 29 | return; 30 | } 31 | if (radii.length === 1 && radii[0] === 0) { 32 | this.rect(x, y, width, height); 33 | return; 34 | } 35 | 36 | // set the corners 37 | // tl = top left radius 38 | // tr = top right radius 39 | // br = bottom right radius 40 | // bl = bottom left radius 41 | const minRadius = Math.min(width, height) / 2; 42 | const tl = Math.min(minRadius, radii[0]); 43 | let tr = tl; 44 | let br = tl; 45 | let bl = tl; 46 | if (radii.length === 2) { 47 | tr = Math.min(minRadius, radii[1]); 48 | bl = tr; 49 | } 50 | if (radii.length === 3) { 51 | tr = Math.min(minRadius, radii[1]); 52 | bl = tr; 53 | br = Math.min(minRadius, radii[2]); 54 | } 55 | if (radii.length === 4) { 56 | tr = Math.min(minRadius, radii[1]); 57 | br = Math.min(minRadius, radii[2]); 58 | bl = Math.min(minRadius, radii[3]); 59 | } 60 | 61 | // begin with closing current path 62 | // this.closePath(); 63 | // let's draw the rounded rectangle 64 | this.moveTo(x, y + height - bl); 65 | this.arcTo(x, y, x + tl, y, tl); 66 | this.arcTo(x + width, y, x + width, y + tr, tr); 67 | this.arcTo(x + width, y + height, x + width - br, y + height, br); 68 | this.arcTo(x, y + height, x, y + height - bl, bl); 69 | this.closePath(); 70 | } 71 | -------------------------------------------------------------------------------- /packages/path2d/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Path2D } from "./path2d.js"; 2 | 3 | export type Command = 4 | | "M" 5 | | "m" 6 | | "L" 7 | | "l" 8 | | "H" 9 | | "h" 10 | | "V" 11 | | "v" 12 | | "A" 13 | | "a" 14 | | "C" 15 | | "c" 16 | | "S" 17 | | "s" 18 | | "Q" 19 | | "q" 20 | | "T" 21 | | "t" 22 | | "Z" 23 | | "z" 24 | | "AC" 25 | | "AT" 26 | | "E" 27 | | "R" 28 | | "RR"; 29 | 30 | export type MovePathCommand = ["m" | "M", number, number]; 31 | export type LinePathCommand = ["l" | "L", number, number]; 32 | export type HorizontalPathCommand = ["h" | "H", number]; 33 | export type VerticalPathCommand = ["v" | "V", number]; 34 | export type ArcPathCommand = ["a" | "A", number, number, number, boolean, boolean, number, number]; 35 | export type CurvePathCommand = ["c" | "C", number, number, number, number, number, number]; 36 | export type ShortCurvePathCommand = ["s" | "S", number, number, number, number]; 37 | export type QuadraticCurvePathCommand = ["q" | "Q", number, number, number, number]; 38 | export type ShortQuadraticCurvePathCommand = ["t" | "T", number, number]; 39 | export type ClosePathCommand = ["z" | "Z"]; 40 | export type ArcCommand = ["AC", number, number, number, number, number, boolean]; 41 | export type ArcToCommand = ["AT", number, number, number, number, number]; 42 | export type EllipseCommand = ["E", number, number, number, number, number, number, number, boolean]; 43 | export type RectCommand = ["R", number, number, number, number]; 44 | export type RoundRectCommand = ["RR", number, number, number, number, number | number[]]; 45 | export type GenericCommand = [Command, ...(number | boolean | number[])[]]; 46 | export type CanvasFillRule = "nonzero" | "evenodd"; 47 | 48 | export type PathCommand = 49 | | MovePathCommand 50 | | LinePathCommand 51 | | HorizontalPathCommand 52 | | VerticalPathCommand 53 | | ArcPathCommand 54 | | CurvePathCommand 55 | | ShortCurvePathCommand 56 | | QuadraticCurvePathCommand 57 | | ShortQuadraticCurvePathCommand 58 | | ClosePathCommand 59 | | ArcCommand 60 | | ArcToCommand 61 | | EllipseCommand 62 | | RectCommand 63 | | RoundRectCommand 64 | | GenericCommand; 65 | 66 | export interface IPath2D { 67 | addPath(path: IPath2D): void; 68 | moveTo(x: number, y: number): void; 69 | lineTo(x: number, y: number): void; 70 | arc(x: number, y: number, r: number, start: number, end: number, ccw: boolean): void; 71 | arcTo(x1: number, y1: number, x2: number, y2: number, r: number): void; 72 | ellipse(x: number, y: number, rx: number, ry: number, angle: number, start: number, end: number, ccw: boolean): void; 73 | closePath(): void; 74 | bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; 75 | quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; 76 | rect(x: number, y: number, width: number, height: number): void; 77 | roundRect(x: number, y: number, width: number, height: number, radii: number | number[]): void; 78 | } 79 | 80 | export interface ICanvasRenderingContext2D { 81 | beginPath(): void; 82 | save(): void; 83 | translate(x: number, y: number): void; 84 | rotate(angle: number): void; 85 | scale(x: number, y: number): void; 86 | restore(): void; 87 | moveTo(x: number, y: number): void; 88 | lineTo(x: number, y: number): void; 89 | arc(x: number, y: number, r: number, start: number, end: number, ccw: boolean): void; 90 | arcTo(x1: number, y1: number, x2: number, y2: number, r: number): void; 91 | ellipse(x: number, y: number, rx: number, ry: number, angle: number, start: number, end: number, ccw: boolean): void; 92 | closePath(): void; 93 | bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; 94 | quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; 95 | rect(x: number, y: number, width: number, height: number): void; 96 | isPointInPath(x: number, y: number, fillRule?: CanvasFillRule): boolean; 97 | isPointInPath(path: Path2D, x: number, y: number, fillRule?: CanvasFillRule): boolean; 98 | clip(fillRule?: CanvasFillRule): void; 99 | clip(path: Path2D, fillRule?: CanvasFillRule): void; 100 | fill(fillRule?: CanvasFillRule): void; 101 | fill(path: Path2D, fillRule?: CanvasFillRule): void; 102 | stroke(): void; 103 | stroke(path: Path2D): void; 104 | roundRect(x: number, y: number, width: number, height: number, radii: number | number[]): void; 105 | } 106 | 107 | export interface ICanvasRenderingContext2DWithoutPath2D { 108 | beginPath(): void; 109 | save(): void; 110 | translate(x: number, y: number): void; 111 | rotate(angle: number): void; 112 | scale(x: number, y: number): void; 113 | restore(): void; 114 | moveTo(x: number, y: number): void; 115 | lineTo(x: number, y: number): void; 116 | arc(x: number, y: number, r: number, start: number, end: number, ccw: boolean): void; 117 | arcTo(x1: number, y1: number, x2: number, y2: number, r: number): void; 118 | ellipse(x: number, y: number, rx: number, ry: number, angle: number, start: number, end: number, ccw: boolean): void; 119 | closePath(): void; 120 | bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; 121 | quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; 122 | rect(x: number, y: number, width: number, height: number): void; 123 | isPointInPath(x: number, y: number, fillRule?: CanvasFillRule): boolean; 124 | // isPointInPath(path: Path2D, x: number, y: number, fillRule?: CanvasFillRule): boolean; 125 | clip(fillRule?: CanvasFillRule): void; 126 | // clip(path: Path2D, fillRule?: CanvasFillRule): void; 127 | fill(fillRule?: CanvasFillRule): void; 128 | // fill(path: Path2D, fillRule?: CanvasFillRule): void; 129 | stroke(): void; 130 | // stroke(path: Path2D): void; 131 | roundRect(x: number, y: number, width: number, height: number, radii: number | number[]): void; 132 | } 133 | 134 | export type IPrototype = { 135 | prototype: T; 136 | new (): T; 137 | }; 138 | export type PartialBy = Omit & { [P in K]?: T[P] }; 139 | export type CanvasRenderingContext2DProt = IPrototype; 140 | -------------------------------------------------------------------------------- /packages/path2d/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true 6 | }, 7 | "include": ["src"], 8 | "exclude": ["src/**/__test__/"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/path2d/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [".*", "**/*"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/path2d/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@qlik/tsconfig/node.json", 3 | "include": ["src", "test"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/path2d/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ["**/node_modules/**", "dist"], 6 | coverage: { 7 | exclude: ["*.cjs", "src/__test__/**", "src/types.ts", "src/index.ts"], 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "build": { 4 | "dependsOn": ["^build"] 5 | }, 6 | "dev": { 7 | "cache": false, 8 | "persistent": true 9 | }, 10 | "lint": {}, 11 | "check-types": {}, 12 | "test": {}, 13 | "format:check": {}, 14 | "format:write": {} 15 | } 16 | } 17 | --------------------------------------------------------------------------------