├── .github └── workflows │ ├── arch.yml │ ├── build.yml │ ├── publish.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── arch ├── arm │ └── Dockerfile ├── glibc │ ├── Dockerfile │ └── install-llvm.sh └── musl │ └── Dockerfile ├── docs ├── api │ ├── app.md │ ├── canvas.md │ ├── context.md │ ├── font-library.md │ ├── image.md │ ├── imagedata.md │ ├── index.md │ ├── path2d.md │ └── window.md ├── assets │ ├── drawCanvas@2x.png │ ├── effect-interpolate@2x.png │ ├── effect-jitter@2x.png │ ├── effect-points@2x.png │ ├── effect-round@2x.png │ ├── effect-simplify@2x.png │ ├── effect-trim@2x.png │ ├── effect-unwind@2x.png │ ├── hero-dark@2x.png │ ├── hero@2x.png │ ├── lineDashMarker@2x.png │ ├── measureText@2x.png │ ├── measureTextBaselines@2x.png │ ├── operation-none.svg │ ├── operations@2x.png │ ├── outlineText@2x.png │ └── projection@2x.png ├── getting-started.md └── index.md ├── lib ├── browser.js ├── classes │ ├── canvas.js │ ├── context.js │ ├── css.js │ ├── geometry.js │ ├── gui.js │ ├── imagery.js │ ├── neon.js │ ├── path.js │ └── typography.js ├── index.d.ts ├── index.js └── index.mjs ├── package-lock.json ├── package.json ├── src ├── canvas.rs ├── context │ ├── api.rs │ ├── mod.rs │ └── page.rs ├── filter.rs ├── font_library.rs ├── gpu │ ├── metal.rs │ ├── mod.rs │ └── vulkan │ │ ├── engine.rs │ │ ├── mod.rs │ │ └── renderer.rs ├── gradient.rs ├── gui │ ├── app.rs │ ├── event.rs │ ├── mod.rs │ ├── window.rs │ └── window_mgr.rs ├── image.rs ├── lib.rs ├── path.rs ├── pattern.rs ├── texture.rs ├── typography.rs └── utils.rs └── test ├── assets ├── AmstelvarAlpha-VF.ttf ├── Monoton-Regular.woff ├── Monoton-Regular.woff2 ├── blend-bg.png ├── blend-fg.png ├── checkers.png ├── globe.jpg ├── grapes.svg ├── halved-1.jpeg ├── halved-2.jpeg ├── image │ ├── format.bmp │ ├── format.gif │ ├── format.ico │ ├── format.jpg │ ├── format.pdf │ ├── format.png │ ├── format.raw │ ├── format.svg │ └── format.webp ├── pentagon-cmyk.jpg ├── pentagon-grayscale.jpg ├── pentagon.png ├── pentagon.raw ├── quadrants.png ├── readme-header-dark@2x.png ├── readme-header@2x.png ├── rose.webp ├── social-card@2x.png ├── star.png ├── state.png └── tree.svg ├── canvas.test.js ├── context2d.test.js ├── media.test.js ├── path2d.test.js └── visual ├── index.html ├── index.js └── tests.js /.github/workflows/arch.yml: -------------------------------------------------------------------------------- 1 | name: Rebuild containers 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build-container: 7 | runs-on: ubuntu-latest 8 | permissions: write-all 9 | strategy: 10 | matrix: 11 | libc: [glibc, musl] 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Login to GitHub Container Registry 24 | uses: docker/login-action@v3 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.repository_owner }} 28 | password: ${{ secrets.CR_PAT }} 29 | 30 | - name: Build and push 31 | uses: docker/build-push-action@v6 32 | with: 33 | tags: ghcr.io/${{ github.repository }}-${{ matrix.libc }}:latest 34 | context: arch/${{ matrix.libc }} 35 | platforms: linux/amd64,linux/arm64 36 | cache-from: type=gha 37 | cache-to: type=gha,mode=max 38 | push: true 39 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Compile binaries 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | linux-x86: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | libc: [glibc, musl] 15 | 16 | container: 17 | image: ${{ format('ghcr.io/{0}-{1}:latest', github.repository, matrix.libc) }} 18 | options: --user github 19 | 20 | steps: 21 | - name: Use Rust 22 | uses: dtolnay/rust-toolchain@stable 23 | with: 24 | toolchain: stable 25 | 26 | - name: Checkout skia-canvas 27 | uses: actions/checkout@v4 28 | 29 | - name: Build module 30 | run: | 31 | mkdir -p $CARGO_HOME/registry 32 | chown -R github $CARGO_HOME/registry 33 | make optimized 34 | 35 | - name: Package module 36 | run: | 37 | npm test && npm run package 38 | 39 | - name: Add to release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | run: | 43 | npm run upload 44 | 45 | linux-arm64: 46 | runs-on: [self-hosted, linux, ARM64] 47 | strategy: 48 | fail-fast: false 49 | matrix: 50 | libc: [glibc, musl] 51 | 52 | container: 53 | image: ${{ format('ghcr.io/{0}-{1}:latest', github.repository, matrix.libc) }} 54 | options: --user github 55 | 56 | steps: 57 | - name: Prepare workspace 58 | run: | 59 | rm -rf "$GITHUB_WORKSPACE" 60 | mkdir -p "$GITHUB_WORKSPACE" 61 | 62 | - name: Install rust 63 | run: | 64 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal 65 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 66 | echo CARGO_TERM_COLOR=always >> $GITHUB_ENV 67 | 68 | - name: Checkout skia-canvas 69 | env: 70 | SERVER: ${{ github.server_url }} 71 | REPO: ${{ github.repository }} 72 | REF: ${{ github.ref_name }} 73 | run: | 74 | git clone --depth 1 --branch $REF ${SERVER}/${REPO} . 75 | 76 | - name: Build module 77 | run: | 78 | make optimized 79 | 80 | - name: Package module 81 | run: | 82 | npm test && npm run package 83 | 84 | - name: Add to release 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | run: | 88 | npm run upload 89 | 90 | mac: 91 | strategy: 92 | fail-fast: false 93 | matrix: 94 | arch: [x86, arm64] 95 | runs-on: ${{ matrix.arch == 'x86' && 'macos-13' || 'macos-14' }} 96 | 97 | steps: 98 | - name: Checkout repository 99 | uses: actions/checkout@v4 100 | 101 | - name: Use Node.js 102 | uses: actions/setup-node@v4 103 | with: 104 | node-version: 16 105 | 106 | - name: Use Rust 107 | uses: dtolnay/rust-toolchain@stable 108 | with: 109 | toolchain: stable 110 | 111 | - name: Use Ninja 112 | uses: seanmiddleditch/gha-setup-ninja@master 113 | 114 | - name: Build module 115 | env: 116 | MACOSX_DEPLOYMENT_TARGET: 10.14 117 | run: make optimized 118 | 119 | - name: Package module 120 | run: npm test && npm run package 121 | 122 | - name: Add to release 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | run: | 126 | npm run upload 127 | 128 | windows-x86: 129 | runs-on: windows-latest 130 | 131 | steps: 132 | - name: Enable long paths 133 | run: git config --system core.longpaths true 134 | 135 | - name: Checkout repository 136 | uses: actions/checkout@v4 137 | 138 | - name: Use Node.js 139 | uses: actions/setup-node@v4 140 | with: 141 | node-version: 16 142 | 143 | - name: Use Rust 144 | uses: dtolnay/rust-toolchain@stable 145 | with: 146 | toolchain: stable 147 | 148 | - name: Use Ninja 149 | uses: seanmiddleditch/gha-setup-ninja@master 150 | 151 | - name: Build module 152 | run: make optimized 153 | 154 | - name: Package module 155 | run: npm test && npm run package 156 | 157 | - name: Add to release 158 | env: 159 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 160 | run: | 161 | npm config set script-shell bash 162 | npm run upload 163 | 164 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | publish: 9 | name: Publish to NPM 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 15 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - name: Publish to NPM 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | run: npm publish 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: New release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | new-release: 9 | name: Create Release 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Auto-generate release 14 | uses: marvinpinto/action-automatic-releases@v1.2.0 15 | with: 16 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 17 | prerelease: false 18 | draft: true 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | workflow_dispatch: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | run-tests: 11 | name: Rebuild & Test 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - macos-latest 18 | - windows-latest 19 | - ubuntu-22.04 20 | node: 21 | - 16 22 | - 22 23 | 24 | steps: 25 | - name: Enable long paths (Windows only) 26 | if: ${{ matrix.os == 'windows-latest' }} 27 | run: git config --system core.longpaths true 28 | 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | cache: 'npm' 37 | 38 | - name: Use Rust 39 | uses: dtolnay/rust-toolchain@stable 40 | with: 41 | toolchain: stable 42 | 43 | - name: Use Ninja 44 | uses: seanmiddleditch/gha-setup-ninja@master 45 | 46 | - name: Build module 47 | run: make optimized 48 | 49 | - name: Run tests 50 | run: | 51 | npm test --verbose 52 | 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .env 4 | _arc 5 | build 6 | lib/v*/*.node 7 | node_modules 8 | native/artifacts.json 9 | native/target 10 | native/*.node 11 | target 12 | test/assets/Oswald 13 | test/assets/Raleway 14 | /*.* -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "skia-canvas" 3 | version = "2.0.2" 4 | description = "A canvas environment for Node" 5 | authors = ["Christian Swinehart "] 6 | license = "MIT" 7 | edition = "2021" 8 | exclude = ["index.node"] 9 | 10 | [lib] 11 | crate-type = ["cdylib"] 12 | 13 | [profile.release] 14 | lto = "fat" 15 | 16 | [features] 17 | freetype = ["skia-safe/embed-freetype", "skia-safe/freetype-woff2"] 18 | metal = ["skia-safe/metal", "dep:metal", "dep:raw-window-handle", "dep:core-graphics-types", "dep:cocoa", "dep:objc"] 19 | vulkan = ["skia-safe/vulkan", "winit/rwh_05", "dep:ash", "dep:vulkano"] 20 | window = ["dep:winit"] 21 | 22 | [dependencies] 23 | neon = "1.0" 24 | crc = "^3.0" 25 | css-color = "^0.2" 26 | rayon = "^1.5" 27 | crossbeam = "0.8.2" 28 | once_cell = "1.13" 29 | serde_json = "1.0" 30 | serde = { version = "1.0", features = ["derive"] } 31 | allsorts = { version = "0.15", features = ["flate2_zlib"], default-features = false} 32 | skia-safe = { version = "0.81.0", features = ["textlayout", "webp", "svg"] } 33 | 34 | # vulkan 35 | ash = { version = "0.37.3", optional = true } 36 | vulkano = { version = "0.34.1", optional = true } 37 | 38 | # metal 39 | metal = { version = "0.29", optional = true } 40 | raw-window-handle = { version = "0.6", optional = true } 41 | core-graphics-types = { version = "0.1.1", optional = true } 42 | cocoa = { version = "0.26.0", optional = true } 43 | objc = { version = "0.2.7", optional = true } 44 | 45 | # window 46 | winit = { version = '0.30.5', features = ["serde"], optional = true } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Swinehart 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAPI_VERSION := 8 2 | NPM := $(CURDIR)/node_modules 3 | NODEMON := $(CURDIR)/node_modules/.bin/nodemon 4 | JEST := $(CURDIR)/node_modules/.bin/jest 5 | LIBDIR := $(CURDIR)/lib/v$(NAPI_VERSION) 6 | LIB := $(LIBDIR)/index.node 7 | LIB_SRC := Cargo.toml $(wildcard src/*.rs) $(wildcard src/*/*.rs) $(wildcard src/*/*/*.rs) 8 | GIT_TAG = $(shell git describe) 9 | PACKAGE_VERSION = $(shell npm run env | grep npm_package_version | cut -d '=' -f 2) 10 | NPM_VERSION = $(shell npm view skia-canvas version) 11 | .PHONY: optimized test debug visual check clean distclean release skia-version with-local-skia run preview 12 | .DEFAULT_GOAL := $(LIB) 13 | 14 | # platform-specific features to be passed to cargo 15 | OS=$(shell sh -c 'uname -s 2>/dev/null') 16 | ifeq ($(OS),Darwin) 17 | FEATURES = metal,window 18 | else # Linux & Windows 19 | FEATURES = vulkan,window,freetype 20 | endif 21 | 22 | $(NPM): 23 | npm ci --ignore-scripts 24 | 25 | $(LIB): $(NPM) $(LIB_SRC) 26 | @npm run build 27 | @touch $(LIB) 28 | 29 | optimized: $(NPM) 30 | @rm -f $(LIB) 31 | @npm run build -- --release --features $(FEATURES) 32 | 33 | test: $(LIB) 34 | @$(JEST) --verbose 35 | 36 | debug: $(LIB) 37 | @$(JEST) --watch 38 | 39 | visual: $(LIB) 40 | @$(NODEMON) test/visual -w native/index.node -w test/visual -e js,html 41 | 42 | check: 43 | cargo check 44 | 45 | clean: 46 | rm -rf $(LIBDIR) 47 | rm -rf $(CURDIR)/target/debug 48 | rm -rf $(CURDIR)/target/release 49 | 50 | distclean: clean 51 | rm -rf $(NPM) 52 | rm -rf $(CURDIR)/build 53 | cargo clean 54 | 55 | release: 56 | @if [[ `git status -s package.json` != "" ]]; then printf "Commit changes to package.json first:\n\n"; git --no-pager diff package.json; exit 1; fi 57 | @if [[ `git cherry -v` != "" ]]; then printf "Unpushed commits:\n\n"; git --no-pager log --branches --not --remotes; exit 1; fi 58 | @if [[ $(GIT_TAG) =~ ^v$(PACKAGE_VERSION) ]]; then printf "Already published $(GIT_TAG)\n"; exit 1; fi 59 | @echo 60 | @echo "Currently on NPM: $(NPM_VERSION)" 61 | @echo "Package Version: $(PACKAGE_VERSION)" 62 | @echo "Last Git Tag: $(GIT_TAG)" 63 | @echo 64 | @/bin/echo -n "Update release -> v$(PACKAGE_VERSION)? [y/N] " 65 | @read line; if [[ $$line = "y" ]]; then printf "\nPushing tag to github..."; else exit 1; fi 66 | git tag -a v$(PACKAGE_VERSION) -m v$(PACKAGE_VERSION) 67 | git push origin --tags 68 | @printf "\nNext: publish the release on github to submit to npm\n" 69 | 70 | # linux-build helpers 71 | skia-version: 72 | @grep -m 1 '^skia-safe' Cargo.toml | egrep -o '[0-9\.]+' 73 | 74 | with-local-skia: 75 | echo '' >> Cargo.toml 76 | echo '[patch.crates-io]' >> Cargo.toml 77 | echo 'skia-safe = { path = "../rust-skia/skia-safe" }' >> Cargo.toml 78 | echo 'skia-bindings = { path = "../rust-skia/skia-bindings" }' >> Cargo.toml 79 | 80 | # debugging 81 | run: $(LIB) 82 | @node check.js 83 | 84 | preview: run 85 | @less out.png || true 86 | -------------------------------------------------------------------------------- /arch/arm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM arm32v7/debian:buster-slim 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y \ 5 | curl build-essential lsb-release wget software-properties-common \ 6 | python2 libssl-dev libfontconfig-dev git clang lldb lld ninja-build 7 | 8 | WORKDIR /usr/local/src 9 | RUN git clone https://gn.googlesource.com/gn && \ 10 | cd gn && \ 11 | python build/gen.py && \ 12 | ninja -C out && \ 13 | cp out/gn /usr/local/bin/gn && \ 14 | rm -rf /usr/local/src/gn 15 | 16 | ENV SKIA_GN_COMMAND="/usr/local/bin/gn" 17 | ENV SKIA_NINJA_COMMAND="/usr/bin/ninja" 18 | 19 | RUN groupadd -r -g 1000 pi 20 | RUN useradd -r -m -u 1000 -g pi pi 21 | USER pi 22 | -------------------------------------------------------------------------------- /arch/glibc/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:buster-slim 2 | ARG TARGETARCH 3 | 4 | ENV PATH="/usr/local/bin:$PATH" \ 5 | SKIA_NINJA_COMMAND="/usr/bin/ninja" 6 | 7 | # basics 8 | RUN apt-get update && \ 9 | apt-get install -y -q \ 10 | perl build-essential software-properties-common \ 11 | libssl-dev libfontconfig-dev 12 | 13 | # upgrade curl and add deps for building git & skia 14 | RUN add-apt-repository "deb http://archive.debian.org/debian buster-backports main" && \ 15 | apt-get update && apt-get install -y -q -t buster-backports\ 16 | curl wget libghc-zlib-dev libcurl4-gnutls-dev gettext ninja-build 17 | 18 | # upgrade git to 2.47 19 | ARG GIT_VERSION=2.47.0 20 | ARG GIT_URL=https://github.com/git/git/archive/refs/tags/v$GIT_VERSION.tar.gz 21 | RUN curl -sL $GIT_URL | tar xz -C /opt && \ 22 | cd /opt/git-$GIT_VERSION && \ 23 | make -j 12 && make prefix=/usr/local install 24 | 25 | # downgrade to the libcurl version that doesn't make git throw HTTP2 errors: 26 | # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=987187 27 | RUN apt-get reinstall -y -q -t buster --allow-downgrades libcurl3-gnutls=7.64.0-4+deb10u2 && \ 28 | apt-mark hold libcurl3-gnutls 29 | 30 | # upgrade clang to v18 31 | ARG LLVM_VERSION=18 32 | COPY install-llvm.sh . 33 | RUN bash install-llvm.sh $LLVM_VERSION 34 | 35 | # install gh 36 | ARG GH_VERSION=2.55.0 37 | ARG GH_URL=https://github.com/cli/cli/releases/download/v$GH_VERSION/gh_${GH_VERSION}_linux_$TARGETARCH.tar.gz 38 | RUN curl -sL $GH_URL | tar xz --strip-components=2 -C /usr/local/bin 39 | 40 | RUN useradd -u 1001 -M github 41 | -------------------------------------------------------------------------------- /arch/glibc/install-llvm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # taken from: 4 | # https://gist.github.com/junkdog/70231d6953592cd6f27def59fe19e50d 5 | 6 | function register_clang_version { 7 | local version=$1 8 | local priority=$2 9 | 10 | update-alternatives \ 11 | --verbose \ 12 | --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-${version} ${priority} \ 13 | --slave /usr/bin/llvm-ar llvm-ar /usr/bin/llvm-ar-${version} \ 14 | --slave /usr/bin/llvm-as llvm-as /usr/bin/llvm-as-${version} \ 15 | --slave /usr/bin/llvm-bcanalyzer llvm-bcanalyzer /usr/bin/llvm-bcanalyzer-${version} \ 16 | --slave /usr/bin/llvm-c-test llvm-c-test /usr/bin/llvm-c-test-${version} \ 17 | --slave /usr/bin/llvm-cat llvm-cat /usr/bin/llvm-cat-${version} \ 18 | --slave /usr/bin/llvm-cfi-verify llvm-cfi-verify /usr/bin/llvm-cfi-verify-${version} \ 19 | --slave /usr/bin/llvm-cov llvm-cov /usr/bin/llvm-cov-${version} \ 20 | --slave /usr/bin/llvm-cvtres llvm-cvtres /usr/bin/llvm-cvtres-${version} \ 21 | --slave /usr/bin/llvm-cxxdump llvm-cxxdump /usr/bin/llvm-cxxdump-${version} \ 22 | --slave /usr/bin/llvm-cxxfilt llvm-cxxfilt /usr/bin/llvm-cxxfilt-${version} \ 23 | --slave /usr/bin/llvm-diff llvm-diff /usr/bin/llvm-diff-${version} \ 24 | --slave /usr/bin/llvm-dis llvm-dis /usr/bin/llvm-dis-${version} \ 25 | --slave /usr/bin/llvm-dlltool llvm-dlltool /usr/bin/llvm-dlltool-${version} \ 26 | --slave /usr/bin/llvm-dwarfdump llvm-dwarfdump /usr/bin/llvm-dwarfdump-${version} \ 27 | --slave /usr/bin/llvm-dwp llvm-dwp /usr/bin/llvm-dwp-${version} \ 28 | --slave /usr/bin/llvm-exegesis llvm-exegesis /usr/bin/llvm-exegesis-${version} \ 29 | --slave /usr/bin/llvm-extract llvm-extract /usr/bin/llvm-extract-${version} \ 30 | --slave /usr/bin/llvm-lib llvm-lib /usr/bin/llvm-lib-${version} \ 31 | --slave /usr/bin/llvm-link llvm-link /usr/bin/llvm-link-${version} \ 32 | --slave /usr/bin/llvm-lto llvm-lto /usr/bin/llvm-lto-${version} \ 33 | --slave /usr/bin/llvm-lto2 llvm-lto2 /usr/bin/llvm-lto2-${version} \ 34 | --slave /usr/bin/llvm-mc llvm-mc /usr/bin/llvm-mc-${version} \ 35 | --slave /usr/bin/llvm-mca llvm-mca /usr/bin/llvm-mca-${version} \ 36 | --slave /usr/bin/llvm-modextract llvm-modextract /usr/bin/llvm-modextract-${version} \ 37 | --slave /usr/bin/llvm-mt llvm-mt /usr/bin/llvm-mt-${version} \ 38 | --slave /usr/bin/llvm-nm llvm-nm /usr/bin/llvm-nm-${version} \ 39 | --slave /usr/bin/llvm-objcopy llvm-objcopy /usr/bin/llvm-objcopy-${version} \ 40 | --slave /usr/bin/llvm-objdump llvm-objdump /usr/bin/llvm-objdump-${version} \ 41 | --slave /usr/bin/llvm-opt-report llvm-opt-report /usr/bin/llvm-opt-report-${version} \ 42 | --slave /usr/bin/llvm-pdbutil llvm-pdbutil /usr/bin/llvm-pdbutil-${version} \ 43 | --slave /usr/bin/llvm-PerfectShuffle llvm-PerfectShuffle /usr/bin/llvm-PerfectShuffle-${version} \ 44 | --slave /usr/bin/llvm-profdata llvm-profdata /usr/bin/llvm-profdata-${version} \ 45 | --slave /usr/bin/llvm-ranlib llvm-ranlib /usr/bin/llvm-ranlib-${version} \ 46 | --slave /usr/bin/llvm-rc llvm-rc /usr/bin/llvm-rc-${version} \ 47 | --slave /usr/bin/llvm-readelf llvm-readelf /usr/bin/llvm-readelf-${version} \ 48 | --slave /usr/bin/llvm-readobj llvm-readobj /usr/bin/llvm-readobj-${version} \ 49 | --slave /usr/bin/llvm-rtdyld llvm-rtdyld /usr/bin/llvm-rtdyld-${version} \ 50 | --slave /usr/bin/llvm-size llvm-size /usr/bin/llvm-size-${version} \ 51 | --slave /usr/bin/llvm-split llvm-split /usr/bin/llvm-split-${version} \ 52 | --slave /usr/bin/llvm-stress llvm-stress /usr/bin/llvm-stress-${version} \ 53 | --slave /usr/bin/llvm-strings llvm-strings /usr/bin/llvm-strings-${version} \ 54 | --slave /usr/bin/llvm-strip llvm-strip /usr/bin/llvm-strip-${version} \ 55 | --slave /usr/bin/llvm-symbolizer llvm-symbolizer /usr/bin/llvm-symbolizer-${version} \ 56 | --slave /usr/bin/llvm-tblgen llvm-tblgen /usr/bin/llvm-tblgen-${version} \ 57 | --slave /usr/bin/llvm-undname llvm-undname /usr/bin/llvm-undname-${version} \ 58 | --slave /usr/bin/llvm-xray llvm-xray /usr/bin/llvm-xray-${version} 59 | 60 | 61 | update-alternatives \ 62 | --verbose \ 63 | --install /usr/bin/clang clang /usr/bin/clang-${version} ${priority} \ 64 | --slave /usr/bin/clang++ clang++ /usr/bin/clang++-${version} \ 65 | --slave /usr/bin/clang-format clang-format /usr/bin/clang-format-${version} \ 66 | --slave /usr/bin/clang-cpp clang-cpp /usr/bin/clang-cpp-${version} \ 67 | --slave /usr/bin/clang-cl clang-cl /usr/bin/clang-cl-${version} \ 68 | --slave /usr/bin/clangd clangd /usr/bin/clangd-${version} \ 69 | --slave /usr/bin/clang-tidy clang-tidy /usr/bin/clang-tidy-${version} \ 70 | --slave /usr/bin/clang-check clang-check /usr/bin/clang-check-${version} \ 71 | --slave /usr/bin/clang-query clang-query /usr/bin/clang-query-${version} \ 72 | --slave /usr/bin/asan_symbolize asan_symbolize /usr/bin/asan_symbolize-${version} \ 73 | --slave /usr/bin/bugpoint bugpoint /usr/bin/bugpoint-${version} \ 74 | --slave /usr/bin/dsymutil dsymutil /usr/bin/dsymutil-${version} \ 75 | --slave /usr/bin/lld lld /usr/bin/lld-${version} \ 76 | --slave /usr/bin/ld.lld ld.lld /usr/bin/ld.lld-${version} \ 77 | --slave /usr/bin/lld-link lld-link /usr/bin/lld-link-${version} \ 78 | --slave /usr/bin/llc llc /usr/bin/llc-${version} \ 79 | --slave /usr/bin/lli lli /usr/bin/lli-${version} \ 80 | --slave /usr/bin/obj2yaml obj2yaml /usr/bin/obj2yaml-${version} \ 81 | --slave /usr/bin/opt opt /usr/bin/opt-${version} \ 82 | --slave /usr/bin/sanstats sanstats /usr/bin/sanstats-${version} \ 83 | --slave /usr/bin/verify-uselistorder verify-uselistorder /usr/bin/verify-uselistorder-${version} \ 84 | --slave /usr/bin/wasm-ld wasm-ld /usr/bin/wasm-ld-${version} \ 85 | --slave /usr/bin/yaml2obj yaml2obj /usr/bin/yaml2obj-${version} 86 | 87 | } 88 | 89 | # install the toolchain 90 | wget https://apt.llvm.org/llvm.sh 91 | bash ./llvm.sh $1 92 | 93 | # add generic links to versioned command names (with priority=100) 94 | register_clang_version $1 100 95 | -------------------------------------------------------------------------------- /arch/musl/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.14 2 | ARG TARGETARCH 3 | 4 | ENV RUSTFLAGS="-C target-feature=-crt-static" \ 5 | CC="clang" \ 6 | CXX="clang++" \ 7 | GN_EXE="gn" \ 8 | SKIA_GN_COMMAND="/usr/bin/gn" \ 9 | SKIA_NINJA_COMMAND="/usr/bin/ninja" 10 | 11 | # basics 12 | RUN apk update && apk add --update --no-cache \ 13 | bash curl git python3 perl build-base g++ linux-headers llvm clang \ 14 | musl-dev openssl-dev fontconfig-dev fontconfig ttf-dejavu 15 | 16 | # add ninja & gn for building skia 17 | RUN apk add --update --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.16/community gn 18 | RUN apk add --update --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community ninja-build 19 | 20 | # install gh 21 | ARG GH_VERSION=2.55.0 22 | ARG GH_URL=https://github.com/cli/cli/releases/download/v$GH_VERSION/gh_${GH_VERSION}_linux_$TARGETARCH.tar.gz 23 | RUN curl -sL $GH_URL | tar xz --strip-components=2 -C /usr/local/bin 24 | 25 | RUN adduser --uid 1001 --no-create-home --disabled-password --gecos "" github 26 | -------------------------------------------------------------------------------- /docs/api/app.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Global window manager and process controller 3 | --- 4 | 5 | # App 6 | 7 | > The `App` global variable is a static class which does not need to be instantiated with `new`. It allows you to access all the windows that are currently on screen, choose a frame rate for the `frame` and `draw` events, and control when the GUI event loop begins and terminates. 8 | 9 | | App Lifecycle | Runtime State | Individual Windows | 10 | | -- | -- | -- | 11 | | [launch()](#launch) | [**running**](#running) | [**windows**](#windows) | 12 | | [quit()](#quit) | [**fps**](#fps) | | 13 | 14 | 15 | ### Properties 16 | 17 | #### `.fps` 18 | By default, each window will attempt to update its display 60 times per second. You can reduce this by setting `App.fps` to a smaller integer value. You can raise it as well but on the majority of LCD monitors you won't see any benefit and are likely to get worse performance as you begin to swamp the CPU with your rendering code. 19 | > This setting is only relevant if you are listening for `frame` or `draw` events on your windows. Otherwise the canvas will only be updated when responding to UI interactions like keyboard and mouse events. 20 | 21 | #### `.running` 22 | A read-only boolean flagging whether the GUI event loop has taken control away from Node in order to display your windows. 23 | 24 | #### `.windows` 25 | An array of references to all of the `Window` objects that have been created and not yet [closed][close]. 26 | 27 | ### Methods 28 | 29 | #### `launch()` 30 | Any `Window` you create will schedule the `App` to begin running as soon as the current function returns. You can make this happen sooner by calling `App.launch` within your code. The `launch()` method will not return until the last window is closed so you may find it handy to place ‘clean up’ code after the `launch()` invocation. 31 | >Note, however, that the `App` **cannot be launched a second time** once it terminates due to limitiations in the underlying platform libraries. 32 | 33 | #### `quit()` 34 | By default your process will terminate once the final window has closed. If you wish to bring things to a swifter conclusion from code, call the `App.quit()` method from one of your event handlers instead. 35 | 36 | 37 | [close]: window.md#close 38 | 39 | -------------------------------------------------------------------------------- /docs/api/font-library.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Inspect your system fonts or load new ones 3 | --- 4 | # FontLibrary 5 | 6 | > The `FontLibrary` global variable is a static class which does not need to be instantiated with `new`. Instead you can access the properties and methods on the global `FontLibrary` you import from the module and its contents will be shared across all canvases you create. 7 | 8 | | Installed Fonts | Loading New Fonts | Typeface Details | 9 | | -- | -- | -- | 10 | | [**families**](#families) | [use()](#use) | [family()](#family) | 11 | | [has()](#has) | [reset()](#reset) | | 12 | 13 | 14 | 15 | ## Properties 16 | 17 | ### `.families` 18 | 19 | The `.families` property contains a list of family names, merging together all the fonts installed on the system and any fonts that have been added manually through the `FontLibrary.use()` method. Any of these names can be passed to `FontLibrary.family()` for more information. 20 | 21 | 22 | ## Methods 23 | 24 | ### `family()` 25 | ```js returns="{family, weights, widths, styles}" 26 | FontLibrary.family(name) 27 | ``` 28 | 29 | If the `name` argument is the name of a known font family, this method will return an object with information about the available weights and styles. For instance, on my system `FontLibrary.family("Avenir Next")` returns: 30 | ```js 31 | { 32 | family: 'Avenir Next', 33 | weights: [ 100, 400, 500, 600, 700, 800 ], 34 | widths: [ 'normal' ], 35 | styles: [ 'normal', 'italic' ] 36 | } 37 | ``` 38 | 39 | Asking for details about an unknown family will return `undefined`. 40 | 41 | ### `has()` 42 | ```js 43 | FontLibrary.has(familyName) 44 | ``` 45 | 46 | Returns `true` if the family is installed on the system or has been added via `FontLibrary.use()`. 47 | 48 | ### `reset()` 49 | 50 | Uninstalls any dynamically loaded fonts that had been added via `FontLibrary.use()`. 51 | 52 | ### `use()` 53 | ```js returns="{family, weight, style, width, file}[]" 54 | FontLibrary.use([...fontPaths]) 55 | FontLibrary.use(familyName, [...fontPaths]) 56 | FontLibrary.use({familyName:[...fontPaths], ...) 57 | ``` 58 | 59 | The `FontLibrary.use()` method allows you to dynamically load local font files and use them with your canvases. It can read fonts in the OpenType (`.otf`), TrueType (`.ttf`), and web-font (`.woff` & `.woff2`) file formats. 60 | 61 | 62 | By default the family name will be take from the font metadata, but this can be overridden by an alias you provide. Since font-wrangling can be messy, `use` can be called in a number of different ways: 63 | 64 | #### with a list of file paths 65 | ```js 66 | import {FontLibrary} from 'skia-canvas' 67 | 68 | // with default family name 69 | FontLibrary.use([ 70 | "fonts/Oswald-Regular.ttf", 71 | "fonts/Oswald-SemiBold.ttf", 72 | "fonts/Oswald-Bold.ttf", 73 | ]) 74 | 75 | // with an alias 76 | FontLibrary.use("Grizwald", [ 77 | "fonts/Oswald-Regular.ttf", 78 | "fonts/Oswald-SemiBold.ttf", 79 | "fonts/Oswald-Bold.ttf", 80 | ]) 81 | ``` 82 | 83 | #### with a list of ‘glob’ patterns 84 | 85 | > Note to Windows users: Due to recent changes to the [glob][glob] module, you must write paths using unix-style _forward_ slashes. Backslashes are now used solely for escaping wildcard characters. 86 | 87 | ```js 88 | // with default family name 89 | FontLibrary.use(['fonts/Crimson_Pro/*.ttf']) 90 | 91 | // with an alias 92 | FontLibrary.use("Stinson", ['fonts/Crimson_Pro/*.ttf']) 93 | ``` 94 | 95 | #### multiple families with aliases 96 | ```js 97 | FontLibrary.use({ 98 | Nieuwveen: ['fonts/AmstelvarAlpha-VF.ttf', 'fonts/AmstelvarAlphaItalic-VF.ttf'], 99 | Fairway: 'fonts/Raleway/*.ttf' 100 | }) 101 | ``` 102 | 103 | The return value will be either a list or an object (matching the style in which it was called) with an entry describing each font file that was added. For instance, one of the entries from the first example could be: 104 | ```js 105 | { 106 | family: 'Grizwald', 107 | weight: 600, 108 | style: 'normal', 109 | width: 'normal', 110 | file: 'fonts/Oswald-SemiBold.ttf' 111 | } 112 | ``` 113 | 114 | 115 | [glob]: https://github.com/isaacs/node-glob/blob/main/changelog.md#80 116 | 117 | -------------------------------------------------------------------------------- /docs/api/image.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Bitmap & vector image container 3 | --- 4 | 5 | # Image 6 | 7 | > Skia Canvas's `Image` object is a stripped-down version of the [standard **Image**][img_element] used in browser environments. Since the Canvas API ignores most of its properties, only the relevant ones have been recreated here. 8 | 9 | | Content | Loading | Event Handlers | 10 | | -- | -- | -- | 11 | | [**src**][img_src] | [**complete**][img_complete] | [**onload**][img_onload] / [**onerror**][img_onerror] | 12 | | [**width**][img_size] / [**height**][img_size] | [decode()][img_decode] | [on()][img_bind] / [off()][img_bind] / [once()][img_bind] | 13 | 14 | 15 | ## Creating `Image` objects 16 | 17 | Before an image file can be drawn to the canvas a number of behind-the-scenes steps have to take place: its data has to be loaded, potentially from a remote system, its format needs to be determined, and its data must be decompressed. As a result, newly created Image objects are not ready for use; instead you must asynchronously wait for them to complete their loading & decoding process before making use of them. 18 | 19 | 20 | ### Callbacks 21 | The traditional way to do this is to set up an event listener waiting for the `load` event. You can do this either by assigning a callback function to the image's [`onload`][img_onload] property, or by using the [on()][img_bind] or [once()][img_bind] methods to set up the listener by name. Once the event handler has been set up, you can then kick off the load process by setting a `src` value for the image: 22 | 23 | ```js 24 | let img = new Image() 25 | img.onload = function(theImage){ 26 | // for non-arrow functions, the image object is also passed as `this` 27 | ctx.drawImage(this, 100, 100) 28 | } 29 | img.src = 'https://skia-canvas.org/icon.png' 30 | ``` 31 | 32 | Or, equivalently: 33 | 34 | ```js 35 | img.on("load", (theImage) => { 36 | // arrow functions can use the image reference passed as an argument 37 | ctx.drawImage(theImage, 100, 100) 38 | }) 39 | ``` 40 | 41 | ### Promises 42 | 43 | If you're setting up an Image within an asynchronous function, you can avoid some of this ‘callback hell’ by using the `await` keyword in combination with the Image's [decode()][img_decode] method. It returns a [Promise][Promise] which resolves only once the load process is complete and the image is ready for use, making it convenient for pausing execution before drawing the image: 44 | 45 | ```js 46 | let img = new Image() 47 | img.src = 'https://skia-canvas.org/icon.png' 48 | await img.decode() 49 | ctx.drawImage(img, 100, 100) 50 | ``` 51 | 52 | To cut down on this repetitive boilerplate, you can also use the [loadImage()][loadimage] utility function which wraps both image creation and loading, allowing for even more concise initialization. For instance, the previous example could be rewritten as: 53 | 54 | ```js 55 | let img = await loadImage('https://skia-canvas.org/icon.png') 56 | ctx.drawImage(img, 100, 100) 57 | ``` 58 | 59 | ## Properties 60 | 61 | ### `.src` 62 | 63 | Setting the `src` property will kick off the loading process. While the browser version of this property requires a string containing a URL, here the `src` can be any of the following: 64 | - an HTTP URL to asynchronously retrieve the image from 65 | - an absolute or relative path pointing to a file on the local system 66 | - a [Data URL][DataURL] with the image data base64-encoded into the string (or [url-encoded][url_encode] in the case of SVG images) 67 | - a [Buffer][Buffer] containing the pre-loaded bytes of a supported image file format 68 | 69 | The images you load can be from a variety of formats: 70 | - Bitmap: `png`, `jpeg`, or `webp` 71 | - Vector: `svg` (but **not** `pdf`, sadly) 72 | 73 | ### `.width` & `.height` 74 | 75 | In the browser these are writable properties that can control the display size of the image within the HTML page. But the context's [`drawImage`][drawImage()] method ignores them in favor of the image's intrinsic size. As a result, Skia Canvas doesn't let you overwrite the `width` and `height` properties (since it would have no effect anyway) and provides them as read-only values derived from the image data. 76 | 77 | :::info[Note] 78 | When loading an image from an SVG file, the intrinsic size may not be defined since the root `` element is not required to have a defined `width` and `height`. In these cases, the Image will be set to a fixed height of `150` and its width will be scaled to a value that matches the aspect ratio of the SVG's `viewbox` size. 79 | ::: 80 | 81 | 82 | ### `.complete` 83 | 84 | A boolean that is `true` once the `src` data has been fetched and parsed. It does **not** necessarily mean and the **Image** is ready to be drawn since the data retrieved may not have been a valid image. In addition your should confirm that the `width` and `height` are non-zero to be sure that loading was successful. 85 | 86 | ### `.onload` & `.onerror` 87 | 88 | For compatibility with browser conventions, event handlers can be set up by assigning functions to the **Image**'s `.onload` and `.onerror` properties. For a more modern-feeling approach, try using [`.on("load", …)`][img_bind] and [`.on("error", …)`][img_bind] instead 89 | 90 | The `.onload` function will be passed a reference to the **Image** as its argument, and the `this` of its function context will also refer to the same **Image** object (presuming it is not defined as an arrow function). 91 | 92 | The `.onerror` function will be called with a reference to the [Error][js_error] that occurred as its sole argument. 93 | 94 | 95 | ## Methods 96 | 97 | ### `decode()` 98 | 99 | ```js returns="Promise" 100 | img.decode() 101 | ``` 102 | 103 | Since image loading frequently occurs asynchronously, it can be convenient to use the `await` keyword to pause execution of your function until the **Image** is ready to be worked with: 104 | ```js 105 | async function main(){ 106 | let img = new Image() 107 | img.src = 'http://example.com/a-very-large-file.jpg' 108 | await img.decode() 109 | // …then do something with `img` now that it's ready 110 | } 111 | ``` 112 | 113 | The browser version of this method returns a Promise that resolves to `undefined` once decoding is complete, but for convenience the Skia Canvas version resolves to a reference to the **Image** object itself: 114 | 115 | ```js 116 | let img = new Image() 117 | img.src = 'http://example.com/a-very-large-file.jpg' 118 | img.decode().then(({width, height}) => 119 | console.log(`dimensions: ${width}×${height}`) 120 | ) 121 | ``` 122 | 123 | ### `on()` / `off()` / `once()` 124 | 125 | ```js returns="Image" 126 | on(eventType, handlerFunction) 127 | off(eventType, handlerFunction) 128 | once(eventType, handlerFunction) 129 | ``` 130 | 131 | The **Image** object is an [Event Emitter][event_emitter] subclass and supports all the standard methods for adding and removing event listeners. The event handlers you create will be able to reference the target image through their `this` variable. 132 | 133 | 134 | ## Events 135 | 136 | The events emitted by the **Image** object both relate to image-loading and can be listened for using the `on()` and `once()` methods. 137 | 138 | ### `load` 139 | 140 | Emitted once data has been retrieved and successfully decoded into one of the supported image file formats. The image will be passed to your callback as the first argument. 141 | 142 | ### `error` 143 | 144 | Emitted if loading was unsuccessful for any reason. An **Error** object with details is passed to your callback as the first argument. 145 | 146 | 147 | 148 | ## `loadImage()` 149 | 150 | ```js returns="Promise" 151 | loadImage(src) 152 | ``` 153 | 154 | The `loadImage` utility function is included to avoid the fiddly, callback-heavy verbosity of the normal Image-loading dance. It combines image creation, loading, and decoding and gives you a single call to `await` before making use of an image: 155 | 156 | ```js 157 | import {loadImage} from 'skia-canvas' 158 | 159 | let img = await loadImage('https://skia-canvas.org/icon.png') 160 | ``` 161 | 162 | Note that you are not limited to web URLs when calling `loadImage`, but can include any of the path, URL, or buffer types listed above for the [**src** property][img_src]. 163 | 164 | [loadimage]: #loadimage 165 | [img_bind]: #on--off--once 166 | [img_src]: #src 167 | [img_complete]: #complete 168 | [img_onload]: #onload--onerror 169 | [img_onerror]: #onload--onerror 170 | [img_size]: #width--height 171 | [img_decode]: #decode 172 | [event_emitter]: https://nodejs.org/api/events.html#class-eventemitter 173 | [Buffer]: https://nodejs.org/api/buffer.html 174 | [Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise 175 | [DataURL]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs 176 | [img_element]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement 177 | [drawImage()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage 178 | [js_error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 179 | [url_encode]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent 180 | 181 | -------------------------------------------------------------------------------- /docs/api/imagedata.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Direct pixel access to image and canvas contents 3 | --- 4 | 5 | # ImageData 6 | 7 | > The `ImageData` object offers a convenient container that bundled raw pixel data with metadata helpful for working with it. Skia Canvas's implementation of the class mirrors the [standard **ImageData**][ImageData]'s structure and behavior, but extends it in a few ways. 8 | 9 | | Dimensions | Format | Pixel Data | 10 | | -- | -- | -- | 11 | | [**width**][imgdata_size] | [**colorSpace**][mdn_ImageData_colorspace] | [**data**][imgdata_data] | 12 | | [**height**][imgdata_size] | [**colorType**][imgdata_colortype] 🧪 | | 13 | | | [**bytesPerPixel**][imgdata_bpp] 🧪 | | 14 | 15 | ## Working with `ImageData` objects 16 | 17 | Empty ImageData objects can be created either by calling the context's `createImageData()` method or the `new ImageData()` constructor: 18 | 19 | ```js 20 | let id = ctx.createImageData(800, 600) 21 | ``` 22 | or, equivalently: 23 | ```js 24 | let id = new ImageData(800, 600) 25 | ``` 26 | 27 | ### Choosing a `colorType` 28 | 29 | By default ImageData represents bitmaps using an `rgba` ordering of color channels in subsequent bytes of the buffer, but there are quite a few other ways of arranging pixel data to choose from. You can specify one by passing an optional settings object with a `colorType` field when creating the object: 30 | 31 | ```js 32 | let bgraData = new ImageData(128, 128, {colorType:'bgra'}) 33 | ``` 34 | 35 | See below for a list of supported [`colorType` formats][imgdata_colortype]. 36 | 37 | ### Manipulating pixels 38 | 39 | Once you've created an ImageData you can access its buffer through its [`data`][imgdata_data] attribute (here using the default `rgba` color type): 40 | 41 | ```js 42 | // you can read pixel values out… 43 | let firstPixel = id.data.slice(0, 4) 44 | let [r, g, b, a] = firstPixel 45 | 46 | // …or write to them, here setting the entire buffer to #F00 47 | for (let i=0; i 166 | [loadimage]: image.md#loadimage 167 | [imgdata_data]: #data 168 | [imgdata_size]: #width--height 169 | [imgdata_colortype]: #colortype 170 | [imgdata_bpp]: #bytesperpixel 171 | [skia_colortype]: https://rust-skia.github.io/doc/skia_safe/enum.ColorType.html 172 | [ImageData]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData 173 | [mdn_ImageData_data]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data 174 | [mdn_ImageData_colorspace]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData/colorSpace 175 | [createPattern()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createPattern 176 | [drawImage()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage 177 | [putImageData()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData 178 | [u8_array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8ClampedArray 179 | 180 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-intro 3 | --- 4 | # API Documentation 5 | 6 | :::info[Note] 7 | Documentation for the key classes and their attributes are listed below—properties are printed in **bold** and methods have parentheses attached to the name. The instances where Skia Canvas’s behavior goes beyond the standard are marked by a 🧪 symbol, linking to further details below. Links to documentation for the web standards Skia Canvas emulates are marked with a 📖. 8 | ::: 9 | 10 | The library exports a number of classes emulating familiar browser objects including: 11 | - [Canvas][mdn_canvas] ⧸ [extensions][canvas] 🧪 12 | - [CanvasGradient][CanvasGradient] 13 | - [CanvasPattern][CanvasPattern] 14 | - [CanvasRenderingContext2D][CanvasRenderingContext2D] ⧸ [extensions][context] 🧪 15 | - [DOMMatrix][DOMMatrix] 16 | - [Image][Image] / [extensions][image] 🧪 17 | - [ImageData][ImageData] / [extensions][imagedata] 🧪 18 | - [Path2D][p2d_mdn] ⧸ [extensions][path2d] 🧪 19 | 20 | In addition, the module contains: 21 | - [FontLibrary][fontlibrary] a global object for inspecting the system’s fonts and loading additional ones 22 | - [Window][window] a class allowing you to display your canvas interactively in an on-screen window 23 | - [App][app] a helper class for coordinating multiple windows in a single script 24 | - [loadImage()][loadimage] a utility function for loading `Image` objects asynchronously 25 | - [loadImageData()][loadimagedata] a utility function for loading `ImageData` objects asynchronously 26 | 27 | ---- 28 | 29 | For detailed notes on the extensions Skia Canvas has made to standard object types, see the corresponding pages: 30 | 31 | import DocCardList from '@theme/DocCardList'; 32 | 33 | 34 | 35 | 36 | [app]: app.md 37 | [canvas]: canvas.md 38 | [context]: context.md 39 | [fontlibrary]: font-library.md 40 | [loadimage]: image.md#loadimage 41 | [image]: image.md 42 | [imagedata]: imagedata.md 43 | [loadimagedata]: imagedata.md#loadimagedata 44 | [path2d]: path2d.md 45 | [window]: window.md 46 | [p2d_mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Path2D 47 | [mdn_canvas]: https://developer.mozilla.org/en-US/docs/Web/API/Canvas 48 | [CanvasGradient]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasGradient 49 | [CanvasPattern]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasPattern 50 | [CanvasRenderingContext2D]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D 51 | [DOMMatrix]: https://developer.mozilla.org/en-US/docs/Web/API/DOMMatrix 52 | [Image]: https://developer.mozilla.org/en-US/docs/Web/API/Image 53 | [ImageData]: https://developer.mozilla.org/en-US/docs/Web/API/ImageData 54 | 55 | -------------------------------------------------------------------------------- /docs/assets/drawCanvas@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/drawCanvas@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-interpolate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-interpolate@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-jitter@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-jitter@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-points@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-points@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-round@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-round@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-simplify@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-simplify@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-trim@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-trim@2x.png -------------------------------------------------------------------------------- /docs/assets/effect-unwind@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/effect-unwind@2x.png -------------------------------------------------------------------------------- /docs/assets/hero-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/hero-dark@2x.png -------------------------------------------------------------------------------- /docs/assets/hero@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/hero@2x.png -------------------------------------------------------------------------------- /docs/assets/lineDashMarker@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/lineDashMarker@2x.png -------------------------------------------------------------------------------- /docs/assets/measureText@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/measureText@2x.png -------------------------------------------------------------------------------- /docs/assets/measureTextBaselines@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/measureTextBaselines@2x.png -------------------------------------------------------------------------------- /docs/assets/operation-none.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/assets/operations@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/operations@2x.png -------------------------------------------------------------------------------- /docs/assets/outlineText@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/outlineText@2x.png -------------------------------------------------------------------------------- /docs/assets/projection@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/docs/assets/projection@2x.png -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Getting Started 4 | --- 5 | ## Installation 6 | 7 | If you’re running on a supported platform, installation should be as simple as: 8 | ```bash 9 | npm install skia-canvas 10 | ``` 11 | 12 | This will download a pre-compiled library from the project’s most recent [release](https://github.com/samizdatco/skia-canvas/releases). 13 | 14 | ## Platform Support 15 | 16 | The underlying Rust library uses [N-API][node_napi] v8 which allows it to run on Node.js versions: 17 | - v12.22+ 18 | - v14.17+ 19 | - v15.12+ 20 | - v16.0.0 and later 21 | 22 | Pre-compiled binaries are available for: 23 | 24 | - Linux (x64 & arm64) 25 | - macOS (x64 & Apple silicon) 26 | - Windows (x64) 27 | 28 | Nearly everything you need is statically linked into the library. A notable exception is the [Fontconfig](https://www.freedesktop.org/wiki/Software/fontconfig/) library which must be installed separately if you’re running on Linux. 29 | 30 | ## Running in Docker 31 | 32 | The library is compatible with Linux systems using [glibc](https://www.gnu.org/software/libc/) 2.28 or later as well as Alpine Linux (x64 & arm64) and the [musl](https://musl.libc.org) C library it favors. In both cases, Fontconfig must be installed on the system for `skia-canvas` to operate correctly. 33 | 34 | If you are setting up a [Dockerfile](https://nodejs.org/en/docs/guides/nodejs-docker-webapp/) that uses [`node`](https://hub.docker.com/_/node) as its basis, the simplest approach is to set your `FROM` image to one of the (Debian-derived) defaults like `node:lts`, `node:18`, `node:16`, `node:14-buster`, `node:12-buster`, `node:bullseye`, `node:buster`, or simply: 35 | ```dockerfile 36 | FROM node 37 | ``` 38 | 39 | You can also use the ‘slim’ image if you manually install fontconfig: 40 | 41 | ```dockerfile 42 | FROM node:slim 43 | RUN apt-get update && apt-get install -y -q --no-install-recommends libfontconfig1 44 | ``` 45 | 46 | If you wish to use Alpine as the underlying distribution, you can start with something along the lines of: 47 | 48 | ```dockerfile 49 | FROM node:alpine 50 | RUN apk update && apk add fontconfig 51 | ``` 52 | 53 | ## Compiling from Source 54 | 55 | If prebuilt binaries aren’t available for your system you’ll need to compile the portions of this library that directly interface with Skia. 56 | 57 | Start by installing: 58 | 59 | 1. The [Rust compiler](https://www.rust-lang.org/tools/install) and cargo package manager using [`rustup`](https://rust-lang.github.io/rustup/) 60 | 2. A C compiler toolchain (either LLVM/Clang or MSVC) 61 | 4. Python 3 (used by Skia's [build process](https://skia.org/docs/user/build/)) 62 | 3. The [Ninja](https://ninja-build.org) build system 63 | 5. On Linux: Fontconfig and OpenSSL 64 | 65 | [Detailed instructions](https://github.com/rust-skia/rust-skia#building) for setting up these dependencies on different operating systems can be found in the ‘Building’ section of the Rust Skia documentation. Once all the necessary compilers and libraries are present, running `npm run build` will give you a usable library (after a fairly lengthy compilation process). 66 | 67 | ## Multithreading 68 | 69 | When rendering canvases in the background (e.g., by using the asynchronous [saveAs][saveAs] or [toBuffer][toBuffer] methods), tasks are spawned in a thread pool managed by the [rayon][rayon] library. By default it will create up to as many threads as your CPU has cores. You can see this default value by inspecting any [Canvas][canvas] object's [`engine.threads`][engine] property. If you wish to override this default, you can set the `SKIA_CANVAS_THREADS` environment variable to your preferred value. 70 | 71 | For example, you can limit your asynchronous processing to two simultaneous tasks by running your script with: 72 | ```bash 73 | SKIA_CANVAS_THREADS=2 node my-canvas-script.js 74 | ``` 75 | 76 | 77 | [canvas]: api/canvas.md 78 | [engine]: api/canvas.md#engine 79 | [saveAs]: api/canvas.md#saveas 80 | [toBuffer]: api/canvas.md#tobuffer 81 | [node_napi]: https://nodejs.org/api/n-api.html#node-api-version-matrix 82 | [rayon]: https://crates.io/crates/rayon 83 | 84 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | hide_title: true 4 | sidebar_position: -1 5 | sidebar_label: "About" 6 | --- 7 | 8 |
9 | 10 | ![Skia Canvas](./assets/hero@2x.png) 11 | ![Skia Canvas](./assets/hero-dark@2x.png) 12 | 13 |
14 | 15 | Skia Canvas is a browser-less implementation of the HTML Canvas drawing API for Node.js. It is based on Google’s [Skia](https://skia.org) graphics engine and, accordingly, produces very similar results to Chrome’s `` element. The library is well suited for use on desktop machines where you can render hardware-accelerated graphics to a window and on the server where it can output a variety of image formats. 16 | 17 | While the primary goal of this project is to provide a reliable emulation of the [standard API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) according to the [spec](https://html.spec.whatwg.org/multipage/canvas.html), it also extends it in a number of areas to take greater advantage of Skia's advanced graphical features and provide a more expressive coding environment. 18 | 19 | In particular, Skia Canvas: 20 | 21 | - is fast and compact since rendering takes place on the GPU and all the heavy lifting is done by native code written in Rust and C++ 22 | - can render to [windows][window] using an OS-native graphics pipeline and provides a browser-like [UI event][win_bind] framework 23 | - generates images in both raster (JPEG, PNG, & WEBP) and vector (PDF & SVG) formats 24 | - can save images to [files][saveAs], return them as [Buffers][toBuffer], or encode [dataURL][toDataURL_ext] strings 25 | - uses native threads in a [user-configurable][multithreading] worker pool for asynchronous rendering and file I/O 26 | - can create [multiple ‘pages’][newPage] on a given canvas and then [output][saveAs] them as a single, multi-page PDF or an image-sequence saved to multiple files 27 | - can [simplify][p2d_simplify], [blunt][p2d_round], [combine][bool-ops], [excerpt][p2d_trim], and [atomize][p2d_points] bézier paths using [efficient](https://www.youtube.com/watch?v=OmfliNQsk88) boolean operations or point-by-point [interpolation][p2d_interpolate] 28 | - provides [3D perspective][createProjection()] transformations in addition to [scaling][scale()], [rotation][rotate()], and [translation][translate()] 29 | - can fill shapes with vector-based [Textures][createTexture()] in addition to bitmap-based [Patterns][createPattern()] and supports line-drawing with custom [markers][lineDashMarker] 30 | - supports the full set of [CSS filter][filter] image processing operators 31 | - offers rich typographic control including: 32 | - multi-line, [word-wrapped][textwrap] text 33 | - line-by-line [text metrics][c2d_measuretext] 34 | - small-caps, ligatures, and other opentype features accessible using standard [font-variant][fontvariant] syntax 35 | - proportional [letter-spacing][letterSpacing], [word-spacing][wordSpacing], and [leading][c2d_font] 36 | - support for [variable fonts][VariableFonts] and transparent mapping of weight values 37 | - use of non-system fonts [loaded][fontlibrary-use] from local files 38 | 39 | ## Example Usage 40 | 41 | ### Generating image files 42 | 43 | ```js 44 | import {Canvas} from 'skia-canvas' 45 | 46 | let canvas = new Canvas(400, 400), 47 | ctx = canvas.getContext("2d"), 48 | {width, height} = canvas; 49 | 50 | let sweep = ctx.createConicGradient(Math.PI * 1.2, width/2, height/2) 51 | sweep.addColorStop(0, "red") 52 | sweep.addColorStop(0.25, "orange") 53 | sweep.addColorStop(0.5, "yellow") 54 | sweep.addColorStop(0.75, "green") 55 | sweep.addColorStop(1, "red") 56 | ctx.strokeStyle = sweep 57 | ctx.lineWidth = 100 58 | ctx.strokeRect(100,100, 200,200) 59 | 60 | // render to multiple destinations using a background thread 61 | async function render(){ 62 | // save a ‘retina’ image... 63 | await canvas.saveAs("rainbox.png", {density:2}) 64 | // ...or use a shorthand for canvas.toBuffer("png") 65 | let pngData = await canvas.png 66 | // ...or embed it in a string 67 | let pngEmbed = `` 68 | } 69 | render() 70 | 71 | // ...or save the file synchronously from the main thread 72 | canvas.saveAsSync("rainbox.pdf") 73 | ``` 74 | 75 | ### Multi-page sequences 76 | 77 | ```js 78 | import {Canvas} from 'skia-canvas' 79 | 80 | let canvas = new Canvas(400, 400), 81 | ctx = canvas.getContext("2d"), 82 | {width, height} = canvas 83 | 84 | for (const color of ['orange', 'yellow', 'green', 'skyblue', 'purple']){ 85 | ctx = canvas.newPage() 86 | ctx.fillStyle = color 87 | ctx.fillRect(0,0, width, height) 88 | ctx.fillStyle = 'white' 89 | ctx.arc(width/2, height/2, 40, 0, 2 * Math.PI) 90 | ctx.fill() 91 | } 92 | 93 | async function render(){ 94 | // save to a multi-page PDF file 95 | await canvas.saveAs("all-pages.pdf") 96 | 97 | // save to files named `page-01.png`, `page-02.png`, etc. 98 | await canvas.saveAs("page-{2}.png") 99 | } 100 | render() 101 | ``` 102 | 103 | ### Rendering to a window 104 | 105 | ```js 106 | import {Window} from 'skia-canvas' 107 | 108 | let win = new Window(300, 300) 109 | win.title = "Canvas Window" 110 | win.on("draw", e => { 111 | let ctx = e.target.canvas.getContext("2d") 112 | ctx.lineWidth = 25 + 25 * Math.cos(e.frame / 10) 113 | ctx.beginPath() 114 | ctx.arc(150, 150, 50, 0, 2 * Math.PI) 115 | ctx.stroke() 116 | 117 | ctx.beginPath() 118 | ctx.arc(150, 150, 10, 0, 2 * Math.PI) 119 | ctx.stroke() 120 | ctx.fill() 121 | }) 122 | ``` 123 | 124 | [bool-ops]: api/path2d.md#complement-difference-intersect-union-and-xor 125 | [c2d_font]: api/context.md#font 126 | [c2d_measuretext]: api/context.md#measuretext 127 | [createProjection()]: api/context.md#createprojection 128 | [createTexture()]: api/context.md#createtexture 129 | [fontlibrary-use]: api/font-library.md#use 130 | [fontvariant]: api/context.md#fontvariant 131 | [lineDashMarker]: api/context.md#linedashmarker 132 | [newPage]: api/canvas.md#newpage 133 | [p2d_interpolate]: api/path2d.md#interpolate 134 | [p2d_points]: api/path2d.md#points 135 | [p2d_round]: api/path2d.md#round 136 | [p2d_simplify]: api/path2d.md#simplify 137 | [p2d_trim]: api/path2d.md#trim 138 | [saveAs]: api/canvas.md#saveas 139 | [textwrap]: api/context.md#textwrap 140 | [toBuffer]: api/canvas.md#tobuffer 141 | [toDataURL_ext]: api/canvas.md#todataurl 142 | [win_bind]: api/window.md#on--off--once 143 | [window]: api/window.md 144 | [multithreading]: getting-started.md#multithreading 145 | [VariableFonts]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide 146 | [filter]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter 147 | [letterSpacing]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/letterSpacing 148 | [wordSpacing]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/wordSpacing 149 | [createPattern()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createPattern 150 | [rotate()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/rotate 151 | [scale()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/scale 152 | [translate()]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/translate 153 | 154 | -------------------------------------------------------------------------------- /lib/classes/canvas.js: -------------------------------------------------------------------------------- 1 | // 2 | // Canvas object & export options 3 | // 4 | 5 | "use strict" 6 | 7 | const {RustClass, core, inspect, REPR} = require('./neon'), 8 | {Image, ImageData, pixelSize} = require('./imagery'), 9 | {toSkMatrix} = require('./geometry') 10 | 11 | class Canvas extends RustClass{ 12 | #contexts 13 | 14 | constructor(width, height){ 15 | super(Canvas).alloc() 16 | this.#contexts = [] 17 | Object.assign(this, {width, height}) 18 | } 19 | 20 | getContext(kind){ 21 | return (kind=="2d") ? this.#contexts[0] || this.newPage() : null 22 | } 23 | 24 | get gpu(){ return this.prop('engine')=='gpu' } 25 | set gpu(mode){ this.prop('engine', !!mode ? 'gpu' : 'cpu') } 26 | 27 | get engine(){ return JSON.parse(this.prop('engine_status')) } 28 | 29 | get width(){ return this.prop('width') } 30 | set width(w){ 31 | this.prop('width', (typeof w=='number' && !Number.isNaN(w) && w>=0) ? w : 300) 32 | if (this.#contexts[0]) this.getContext("2d").ƒ('resetSize', core(this)) 33 | } 34 | 35 | get height(){ return this.prop('height') } 36 | set height(h){ 37 | this.prop('height', h = (typeof h=='number' && !Number.isNaN(h) && h>=0) ? h : 150) 38 | if (this.#contexts[0]) this.getContext("2d").ƒ('resetSize', core(this)) 39 | } 40 | 41 | newPage(width, height){ 42 | const {CanvasRenderingContext2D} = require('./context') 43 | let ctx = new CanvasRenderingContext2D(this) 44 | this.#contexts.unshift(ctx) 45 | if (arguments.length==2){ 46 | Object.assign(this, {width, height}) 47 | } 48 | return ctx 49 | } 50 | 51 | get pages(){ 52 | return this.#contexts.slice().reverse() 53 | } 54 | 55 | get png(){ return this.toBuffer("png") } 56 | get jpg(){ return this.toBuffer("jpg") } 57 | get pdf(){ return this.toBuffer("pdf") } 58 | get svg(){ return this.toBuffer("svg") } 59 | get webp(){ return this.toBuffer("webp") } 60 | 61 | saveAs(filename, opts={}){ 62 | opts = typeof opts=='number' ? {quality:opts} : opts 63 | let {format, quality, pages, padding, pattern, density, outline, matte, msaa, colorType} = exportOptions(this.pages, {filename, ...opts}), 64 | args = [pages.map(core), pattern, padding, {format, quality, density, outline, matte, msaa, colorType}] 65 | return this.ƒ("save", ...args) 66 | } 67 | 68 | saveAsSync(filename, opts={}){ 69 | opts = typeof opts=='number' ? {quality:opts} : opts 70 | let {format, quality, pages, padding, pattern, density, outline, matte, msaa, colorType} = exportOptions(this.pages, {filename, ...opts}) 71 | this.ƒ("saveSync", pages.map(core), pattern, padding, {format, quality, density, outline, matte, msaa, colorType}) 72 | } 73 | 74 | toBuffer(extension="png", opts={}){ 75 | opts = typeof opts=='number' ? {quality:opts} : opts 76 | let {format, quality, pages, density, outline, matte, msaa, colorType} = exportOptions(this.pages, {extension, ...opts}), 77 | args = [pages.map(core), {format, quality, density, outline, matte, msaa, colorType}]; 78 | return this.ƒ("toBuffer", ...args) 79 | } 80 | 81 | toBufferSync(extension="png", opts={}){ 82 | opts = typeof opts=='number' ? {quality:opts} : opts 83 | let {format, quality, pages, density, outline, matte, msaa, colorType} = exportOptions(this.pages, {extension, ...opts}) 84 | return this.ƒ("toBufferSync", pages.map(core), {format, quality, density, outline, matte, msaa, colorType}) 85 | } 86 | 87 | toDataURL(extension="png", opts={}){ 88 | opts = typeof opts=='number' ? {quality:opts} : opts 89 | let {mime} = exportOptions(this.pages, {extension, ...opts}), 90 | buffer = this.toBuffer(extension, opts); 91 | return buffer.then(data => `data:${mime};base64,${data.toString('base64')}`) 92 | } 93 | 94 | toDataURLSync(extension="png", opts={}){ 95 | opts = typeof opts=='number' ? {quality:opts} : opts 96 | let {mime} = exportOptions(this.pages, {extension, ...opts}), 97 | buffer = this.toBufferSync(extension, opts); 98 | return `data:${mime};base64,${buffer.toString('base64')}` 99 | } 100 | 101 | 102 | [REPR](depth, options) { 103 | let {width, height, gpu, engine, pages} = this 104 | return `Canvas ${inspect({width, height, gpu, engine, pages}, options)}` 105 | } 106 | } 107 | 108 | class CanvasGradient extends RustClass{ 109 | constructor(style, ...coords){ 110 | super(CanvasGradient) 111 | style = (style || "").toLowerCase() 112 | if (['linear', 'radial', 'conic'].includes(style)) this.init(style, ...coords) 113 | else throw new Error(`Function is not a constructor (use CanvasRenderingContext2D's "createConicGradient", "createLinearGradient", and "createRadialGradient" methods instead)`) 114 | } 115 | 116 | addColorStop(offset, color){ 117 | if (offset>=0 && offset<=1) this.ƒ('addColorStop', offset, color) 118 | else throw new Error("Color stop offsets must be between 0.0 and 1.0") 119 | } 120 | 121 | [REPR](depth, options) { 122 | return `CanvasGradient (${this.ƒ("repr")})` 123 | } 124 | } 125 | 126 | class CanvasPattern extends RustClass{ 127 | constructor(canvas, src, repeat){ 128 | super(CanvasPattern) 129 | if (src instanceof Image){ 130 | let {width, height} = canvas 131 | this.init('from_image', core(src), width, height, repeat) 132 | }else if (src instanceof ImageData){ 133 | this.init('from_image_data', src, repeat) 134 | }else if (src instanceof Canvas){ 135 | let ctx = src.getContext('2d') 136 | this.init('from_canvas', core(ctx), repeat) 137 | }else{ 138 | throw new Error("CanvasPatterns require a source Image or a Canvas") 139 | } 140 | } 141 | 142 | setTransform(matrix) { this.ƒ('setTransform', toSkMatrix.apply(null, arguments)) } 143 | 144 | [REPR](depth, options) { 145 | return `CanvasPattern (${this.ƒ("repr")})` 146 | } 147 | } 148 | 149 | class CanvasTexture extends RustClass{ 150 | constructor(spacing, {path, line, color, angle, offset=0}={}){ 151 | super(CanvasTexture) 152 | let [x, y] = typeof offset=='number' ? [offset, offset] : offset.slice(0, 2) 153 | let [h, v] = typeof spacing=='number' ? [spacing, spacing] : spacing.slice(0, 2) 154 | path = core(path) 155 | line = line != null ? line : (path ? 0 : 1) 156 | angle = angle != null ? angle : (path ? 0 : -Math.PI / 4) 157 | this.alloc(path, color, line, angle, h, v, x, y) 158 | } 159 | 160 | [REPR](depth, options) { 161 | return `CanvasTexture (${this.ƒ("repr")})` 162 | } 163 | } 164 | 165 | 166 | // 167 | // Mime type <-> File extension mappings 168 | // 169 | 170 | class Format{ 171 | constructor(){ 172 | let png = "image/png", 173 | jpg = "image/jpeg", 174 | jpeg = "image/jpeg", 175 | webp = "image/webp", 176 | pdf = "application/pdf", 177 | svg = "image/svg+xml", 178 | raw = "application/octet-stream" 179 | 180 | Object.assign(this, { 181 | toMime: this.toMime.bind(this), 182 | fromMime: this.fromMime.bind(this), 183 | expected: `"png", "jpg", "webp", "raw", "pdf", or "svg"`, 184 | formats: {png, jpg, jpeg, webp, raw, pdf, svg}, 185 | mimes: {[png]: "png", [jpg]: "jpg", [webp]: "webp", [raw]: "raw", [pdf]: "pdf", [svg]: "svg"}, 186 | }) 187 | } 188 | 189 | toMime(ext){ 190 | return this.formats[(ext||'').replace(/^\./, '').toLowerCase()] 191 | } 192 | 193 | fromMime(mime){ 194 | return this.mimes[mime] 195 | } 196 | } 197 | 198 | // 199 | // Validation of the options dict shared by the `saveAs`, `toBuffer`, and `toDataURL` methods 200 | // 201 | 202 | const {basename, extname} = require('path') 203 | 204 | function exportOptions(pages, {filename='', extension='', format, page, quality, matte, density, outline, msaa, colorType}={}){ 205 | var {fromMime, toMime, expected} = new Format(), 206 | ext = format || extension.replace(/@\d+x$/i,'') || extname(filename), 207 | format = fromMime(toMime(ext) || ext), 208 | mime = toMime(format), 209 | pp = pages.length 210 | 211 | if (!ext) throw new Error(`Cannot determine image format (use a filename extension or 'format' argument)`) 212 | if (!format) throw new Error(`Unsupported file format "${ext}" (expected ${expected})`) 213 | if (!pp) throw new RangeError(`Canvas has no associated contexts (try calling getContext or newPage first)`) 214 | 215 | let padding, isSequence, pattern = filename.replace(/{(\d*)}/g, (_, width) => { 216 | isSequence = true 217 | width = parseInt(width, 10) 218 | padding = isFinite(width) ? width : isFinite(padding) ? padding : -1 219 | return "{}" 220 | }) 221 | 222 | // allow negative indexing if a specific page is specified 223 | let idx = page > 0 ? page - 1 224 | : page < 0 ? pp + page 225 | : undefined; 226 | 227 | if (isFinite(idx) && idx < 0 || idx >= pp) throw new RangeError( 228 | pp == 1 ? `Canvas only has a ‘page 1’ (${idx} is out of bounds)` 229 | : `Canvas has pages 1–${pp} (${idx} is out of bounds)` 230 | ) 231 | 232 | pages = isFinite(idx) ? [pages[idx]] 233 | : isSequence || format=='pdf' ? pages 234 | : pages.slice(-1) // default to the 'current' context 235 | 236 | if (quality===undefined){ 237 | quality = 0.92 238 | }else{ 239 | if (typeof quality!='number' || !isFinite(quality) || quality<0 || quality>1){ 240 | throw new TypeError("The quality option must be an number in the 0.0–1.0 range") 241 | } 242 | } 243 | 244 | if (density===undefined){ 245 | let m = (extension || basename(filename, ext)).match(/@(\d+)x$/i) 246 | density = m ? parseInt(m[1], 10) : 1 247 | }else if (typeof density!='number' || !Number.isInteger(density) || density<1){ 248 | throw new TypeError("The density option must be a non-negative integer") 249 | } 250 | 251 | if (outline===undefined){ 252 | outline = true 253 | }else if (format == 'svg'){ 254 | outline = !!outline 255 | } 256 | 257 | if (msaa===undefined) { 258 | msaa = undefined // leave as-is 259 | }else if (!msaa){ 260 | msaa = 0 // null, false, etc. should all disable it 261 | }else if (typeof msaa!='number' || !isFinite(msaa) || msaa<0){ 262 | throw new TypeError("The number of MSAA samples must be an integer ≥0") 263 | } 264 | 265 | if (colorType!==undefined){ 266 | pixelSize(colorType) // throw an error if invalid 267 | } 268 | 269 | return {filename, pattern, format, mime, pages, padding, quality, matte, density, outline, msaa, colorType} 270 | } 271 | 272 | module.exports = {Canvas, CanvasGradient, CanvasPattern, CanvasTexture} 273 | -------------------------------------------------------------------------------- /lib/classes/gui.js: -------------------------------------------------------------------------------- 1 | // 2 | // Windows & event handling 3 | // 4 | 5 | "use strict" 6 | 7 | const {EventEmitter} = require('events'), 8 | {RustClass, core, inspect, neon, REPR} = require('./neon'), 9 | {Canvas} = require('./canvas'), 10 | css = require('./css') 11 | 12 | const checkSupport = () => { 13 | if (!neon.App) throw new Error("Skia Canvas was compiled without window support") 14 | } 15 | 16 | class App extends RustClass{ 17 | static #locale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE 18 | #running 19 | #fps 20 | 21 | constructor(){ 22 | super(App) 23 | this.#running = false 24 | this.#fps = 60 25 | } 26 | 27 | get windows(){ return [...GUI.windows] } 28 | get running(){ return this.#running } 29 | get fps(){ return this.#fps } 30 | set fps(rate){ 31 | checkSupport() 32 | if (rate >= 1 && rate != this.#fps){ 33 | this.#fps = this.ƒ('setRate', rate) 34 | } 35 | } 36 | 37 | launch(){ 38 | checkSupport() 39 | 40 | if (this.#running) return console.error('Application is already running') 41 | this.#running = true 42 | clearTimeout(GUI.launcher) 43 | 44 | // begin event loop (and never return) 45 | this.ƒ("launch", args => { 46 | let {ui, state, geom} = JSON.parse(args) 47 | 48 | // in the initial roundtrip only, merge the autogenerated window locations with the specs 49 | for (const [id, {top, left}] of Object.entries(geom || {})){ 50 | GUI.getWindow(id, win => { 51 | win.left = win.left || left 52 | win.top = win.top || top 53 | }) 54 | } 55 | 56 | // update local state based on ui modifications (and evict GUI.windows that have been closed) 57 | if (state) GUI.windows = GUI.windows.filter(win => { 58 | return win.state.id in (state || {}) && Object.assign(win, state[win.state.id]) 59 | }) 60 | 61 | // deliver ui events to corresponding windows 62 | for (const [id, events] of Object.entries(ui || {})){ 63 | GUI.getWindow(id, (win, frame) => { 64 | let modifiers = {} 65 | for (const [[type, e]] of events.map(o => Object.entries(o))){ 66 | switch(type){ 67 | case 'modifiers': 68 | var {control_key:ctrlKey, alt_key:altKey, super_key:metaKey, shift_key:shiftKey} = e 69 | modifiers = {ctrlKey, altKey, metaKey, shiftKey} 70 | break 71 | 72 | case 'mouse': 73 | var {button, x, y, pageX, pageY} = e 74 | e.events.forEach(type => win.emit(type, {x, y, pageX, pageY, button, ...modifiers})) 75 | break 76 | 77 | case 'input': 78 | win.emit(type, {data:e, inputType:'insertText'}) 79 | break 80 | 81 | case 'composition': 82 | win.emit(e.event, {data:e.data, locale:App.#locale}) 83 | break 84 | 85 | case 'keyboard': 86 | var {event, key, code, location, repeat} = e, 87 | defaults = true; 88 | 89 | win.emit(event, {key, code, location, repeat, ...modifiers, 90 | preventDefault:() => defaults = false 91 | }) 92 | 93 | // apply default keybindings unless e.preventDefault() was run 94 | if (defaults && event=='keydown' && !repeat){ 95 | let {ctrlKey, altKey, metaKey} = modifiers 96 | if ( (metaKey && key=='w') || (ctrlKey && key=='c') || (altKey && key=='F4') ){ 97 | win.close() 98 | }else if ( (metaKey && key=='f') || (altKey && key=='F8') ){ 99 | win.fullscreen = !win.fullscreen 100 | } 101 | } 102 | break 103 | 104 | case 'focus': 105 | if (e) win.emit('focus') 106 | else win.emit('blur') 107 | break 108 | 109 | case 'resize': 110 | if (win.fit == 'resize'){ 111 | win.ctx.prop('size', e.width, e.height) 112 | win.canvas.prop('width', e.width) 113 | win.canvas.prop('height', e.height) 114 | } 115 | win.emit(type, e) 116 | break 117 | 118 | case 'move': 119 | case 'wheel': 120 | win.emit(type, e) 121 | break 122 | 123 | case 'fullscreen': 124 | win.emit(type, {enabled: e}) 125 | break 126 | 127 | default: 128 | console.log(type, e); 129 | } 130 | } 131 | }) 132 | } 133 | 134 | // provide frame updates to prompt redraws 135 | GUI.nextFrame((win, frame) => { 136 | if (frame==0) win.emit("setup") 137 | win.emit("frame", {frame}) 138 | if (win.listenerCount('draw')){ 139 | win.canvas.width = win.canvas.width 140 | win.emit("draw", {frame}) 141 | } 142 | }) 143 | 144 | // refresh lazily if not doing a flipbook animation 145 | this.ƒ('setRate', GUI.needsFrameUpdates() ? this.#fps : 0) 146 | 147 | // update the display 148 | return [ 149 | JSON.stringify( GUI.windows.map(win => win.state) ), 150 | GUI.windows.map(win => core(win.canvas.pages[win.page-1]) ) 151 | ] 152 | }) 153 | 154 | GUI.windows = [] // if the launch call exited, the last window was closed 155 | } 156 | 157 | quit(){ 158 | this.ƒ("quit") 159 | } 160 | } 161 | 162 | class Window extends EventEmitter{ 163 | static #kwargs = "left,top,width,height,title,page,background,fullscreen,cursor,fit,visible,resizable".split(/,/) 164 | #canvas 165 | #state 166 | 167 | // accept either ƒ(width, height, {…}) or ƒ({…}) 168 | constructor(width=512, height=512, opts={}){ 169 | checkSupport() 170 | 171 | if (!Number.isFinite(width) || !Number.isFinite(height)){ 172 | opts = [...arguments].slice(-1)[0] || {} 173 | width = opts.width || (opts.canvas || {}).width || 512 174 | height = opts.height || (opts.canvas || {}).height || 512 175 | } 176 | 177 | let canvas = (opts.canvas instanceof Canvas) ? opts.canvas : new Canvas(width, height) 178 | 179 | super(Window) 180 | this.#state = { 181 | title: "", 182 | visible: true, 183 | resizable: true, 184 | background: "white", 185 | fullscreen: false, 186 | page: canvas.pages.length, 187 | left: undefined, 188 | top: undefined, 189 | width, 190 | height, 191 | cursor: "default", 192 | cursorHidden: false, 193 | fit: "contain", 194 | id: Math.random().toString(16) 195 | } 196 | 197 | Object.assign(this, {canvas}, Object.fromEntries( 198 | Object.entries(opts).filter(([k, v]) => Window.#kwargs.includes(k) && v!==undefined) 199 | )) 200 | 201 | GUI.openWindow(this) 202 | } 203 | 204 | get state(){ return this.#state } 205 | get ctx(){ return this.#canvas.pages[this.page-1] } 206 | 207 | get canvas(){ return this.#canvas } 208 | set canvas(canvas){ 209 | if (canvas instanceof Canvas){ 210 | canvas.getContext("2d") // ensure it has at least one page 211 | this.#canvas = canvas 212 | this.#state.page = canvas.pages.length 213 | } 214 | } 215 | 216 | get visible(){ return this.#state.visible } 217 | set visible(flag){ this.#state.visible = !!flag } 218 | 219 | get resizable(){ return this.#state.resizable } 220 | set resizable(flag){ this.#state.resizable = !!flag } 221 | 222 | get fullscreen(){ return this.#state.fullscreen } 223 | set fullscreen(flag){ this.#state.fullscreen = !!flag } 224 | 225 | get title(){ return this.#state.title } 226 | set title(txt){ this.#state.title = (txt != null ? txt : '').toString() } 227 | 228 | get cursor(){ return this.#state.cursorHidden ? 'none' : this.#state.cursor } 229 | set cursor(icon){ 230 | if (css.cursor(icon)){ 231 | this.#state.cursorHidden = icon == 'none' 232 | if (icon != 'none') this.#state.cursor = icon 233 | } 234 | } 235 | 236 | get fit(){ return this.#state.fit } 237 | set fit(mode){ if (css.fit(mode)) this.#state.fit = mode } 238 | 239 | get left(){ return this.#state.left } 240 | set left(val){ if (Number.isFinite(val)) this.#state.left = val } 241 | 242 | get top(){ return this.#state.top } 243 | set top(val){ if (Number.isFinite(val)) this.#state.top = val } 244 | 245 | get width(){ return this.#state.width } 246 | set width(val){ if (Number.isFinite(val)) this.#state.width = val } 247 | 248 | get height(){ return this.#state.height } 249 | set height(val){ if (Number.isFinite(val)) this.#state.height = val } 250 | 251 | get page(){ return this.#state.page } 252 | set page(val){ 253 | if (val < 0) val += this.#canvas.pages.length + 1 254 | let page = this.#canvas.pages[val-1] 255 | if (page && this.#state.page != val){ 256 | let [width, height] = page.prop('size') 257 | this.#canvas.prop('width', width) 258 | this.#canvas.prop('height', height) 259 | this.#state.page = val 260 | } 261 | } 262 | 263 | get background(){ return this.#state.background } 264 | set background(c){ this.#state.background = (c != null ? c : '').toString() } 265 | 266 | emit(type, e){ 267 | // report errors in event-handlers but don't crash 268 | try{ super.emit(type, Object.assign({target:this, type}, e)) } 269 | catch(err){ console.error(err) } 270 | } 271 | 272 | close(){ GUI.closeWindow(this) } 273 | 274 | [REPR](depth, options) { 275 | let info = Object.fromEntries(Window.#kwargs.map(k => [k, this.#state[k]])) 276 | return `Window ${inspect(info, options)}` 277 | } 278 | } 279 | 280 | const GUI = { 281 | App: new App(), 282 | windows: [], 283 | frames: new WeakMap(), 284 | launcher: null, 285 | 286 | nextFrame(callback){ 287 | GUI.windows.forEach(win => { 288 | let frame = GUI.frames.get(win) || 0 289 | GUI.frames.set(win, frame + 1) 290 | callback(win, frame) 291 | }) 292 | }, 293 | 294 | needsFrameUpdates(){ 295 | let names = GUI.windows.map(win => win.eventNames()).flat() 296 | return (names.includes('frame') || names.includes('draw')) 297 | }, 298 | 299 | getWindow(id, callback){ 300 | GUI.windows.filter(w => w.state.id==id).forEach(win => callback(win)) 301 | }, 302 | 303 | openWindow(win){ 304 | GUI.windows.push(win) 305 | if (!GUI.launcher) GUI.launcher = setTimeout( () => GUI.App.launch() ) 306 | neon.App.openWindow(JSON.stringify(win.state), core(win.canvas.pages[win.state.page-1])) 307 | }, 308 | 309 | closeWindow(win){ 310 | GUI.windows = GUI.windows.filter(w => w !== win) 311 | neon.App.closeWindow(win.state.id) 312 | } 313 | } 314 | 315 | module.exports = {App:GUI.App, Window} 316 | -------------------------------------------------------------------------------- /lib/classes/imagery.js: -------------------------------------------------------------------------------- 1 | // 2 | // Image & ImageData 3 | // 4 | 5 | "use strict" 6 | 7 | const {RustClass, core, readOnly, inspect, neon, REPR} = require('./neon'), 8 | {EventEmitter} = require('events'), 9 | {readFile} = require('fs/promises'), 10 | get = require('simple-get') 11 | 12 | const loadImage = src => Object.assign(new Image(), {src}).decode() 13 | const loadImageData = (src, ...args) => new Promise((res,rej) => 14 | Image.fetchData(src, ({data}) => res(new ImageData(data, ...args)), err => rej(err)) 15 | ) 16 | 17 | class Image extends RustClass { 18 | #fetch 19 | #err 20 | 21 | constructor() { 22 | super(Image).alloc() 23 | } 24 | 25 | get complete(){ return this.prop('complete') } 26 | get height(){ return this.prop('height') } 27 | get width(){ return this.prop('width') } 28 | 29 | #onload 30 | get onload(){ return this.#onload } 31 | set onload(cb){ 32 | if (this.#onload) this.off('load', this.#onload) 33 | this.#onload = typeof cb=='function' ? cb : null 34 | if (this.#onload) this.on('load', this.#onload) 35 | } 36 | 37 | #onerror 38 | get onerror(){ return this.#onerror } 39 | set onerror(cb){ 40 | if (this.#onerror) this.off('error', this.#onerror) 41 | this.#onerror = typeof cb=='function' ? cb : null 42 | if (this.#onerror) this.on('error', this.#onerror) 43 | } 44 | 45 | get src(){ return this.prop('src') } 46 | set src(src){ 47 | const request = this.#fetch = {}, // use an empty object as a unique token 48 | loaded = ({data, src}) => { 49 | if (request === this.#fetch){ // confirm this is the most recent request with === 50 | this.#fetch = undefined 51 | this.prop("src", src) 52 | this.#err = this.prop("data", data) ? null : new Error("Could not decode image data") 53 | if (this.#err) this.emit('error', this.#err) 54 | else this.emit('load', this) 55 | } 56 | }, 57 | failed = (err) => { 58 | this.#fetch = undefined 59 | this.#err = err 60 | this.prop("data", Buffer.alloc(0)) 61 | this.emit('error', err) 62 | } 63 | 64 | this.prop("src", typeof src=='string' ? src : '') 65 | Image.fetchData(src, loaded, failed) 66 | } 67 | 68 | static fetchData(src, ok, fail){ 69 | if (Buffer.isBuffer(src)) { 70 | // already loaded 71 | ok({data:src, src:''}) 72 | } else if (typeof src != 'string') { 73 | fail(new Error("'src' property value is neither string nor Buffer type.'")) 74 | } else if (src.startsWith('data:')) { 75 | // data URI 76 | let [header, mime, enc] = src.slice(0, 40).match(/^\s*data:(?[^;]*);(?:charset=)?(?[^,]*),/) || [] 77 | if (!mime || !enc){ 78 | throw new Error(`Invalid data URI header`) 79 | } else { 80 | let content = src.slice(header.length) 81 | if (enc.toLowerCase() != 'base64'){ 82 | content = decodeURIComponent(content) 83 | } 84 | ok({data:Buffer.from(content, enc), src:''}) 85 | } 86 | } else if (/^\s*https?:\/\//.test(src)) { 87 | // remote URL 88 | get.concat(src, (err, res, data) => { 89 | let code = (res || {}).statusCode 90 | if (err) fail(err) 91 | else if (code < 200 || code >= 300) { 92 | fail(new Error(`Failed to load image from "${src}" (error ${code})`)) 93 | } else { 94 | ok({data, src}) 95 | } 96 | }) 97 | } else { 98 | // local file path 99 | readFile(src).then(data => ok({data, src})).catch(e => fail(e)) 100 | } 101 | } 102 | 103 | decode(){ 104 | return this.#fetch ? new Promise((res, rej) => this.once('load', res).once('error', rej) ) 105 | : this.#err ? Promise.reject(this.#err) 106 | : this.complete ? Promise.resolve(this) 107 | : Promise.reject(new Error("Image source not set")) 108 | } 109 | 110 | [REPR](depth, options) { 111 | let {width, height, complete, src} = this 112 | options.maxStringLength = src.match(/^data:/) ? 128 : Infinity; 113 | return `Image ${inspect({width, height, complete, src}, options)}` 114 | } 115 | } 116 | 117 | // Mix the EventEmitter properties into Image 118 | Object.assign(Image.prototype, EventEmitter.prototype) 119 | 120 | 121 | class ImageData{ 122 | constructor(...args){ 123 | if (args[0] instanceof ImageData){ 124 | var {data, width, height, colorSpace, colorType, bytesPerPixel} = args[0] 125 | }else if (args[0] instanceof Image){ 126 | var [image, {colorSpace='srgb', colorType='rgba'}={}] = args, 127 | {width, height} = image, 128 | bytesPerPixel = pixelSize(colorType), 129 | buffer = neon.Image.pixels(core(image), {colorType}), 130 | data = new Uint8ClampedArray(buffer) 131 | }else if (args[0] instanceof Uint8ClampedArray || args[0] instanceof Buffer){ 132 | var [data, width, height, {colorSpace='srgb', colorType='rgba'}={}] = args, 133 | bytesPerPixel = pixelSize(colorType) // validates the string as side effect 134 | 135 | height = height || data.length / width / bytesPerPixel 136 | data = data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data) 137 | if (data.length / bytesPerPixel != width * height){ 138 | throw new Error("ImageData dimensions must match buffer length") 139 | } 140 | }else{ 141 | var [width, height, {colorSpace='srgb', colorType='rgba'}={}] = args, 142 | bytesPerPixel = pixelSize(colorType) 143 | } 144 | 145 | if (!['srgb'].includes(colorSpace)){ // TODO: add display-p3 when supported… 146 | throw new Error(`Unsupported colorSpace: ${colorSpace}`) 147 | } 148 | 149 | if (!Number.isInteger(width) || !Number.isInteger(height) || width < 0 || height < 0){ 150 | throw new Error("ImageData dimensions must be positive integers") 151 | } 152 | 153 | readOnly(this, "colorSpace", colorSpace) 154 | readOnly(this, "colorType", colorType) 155 | readOnly(this, "width", width) 156 | readOnly(this, "height", height) 157 | readOnly(this, 'bytesPerPixel', bytesPerPixel) 158 | readOnly(this, "data", data || new Uint8ClampedArray(width * height * bytesPerPixel)) 159 | } 160 | 161 | [REPR](depth, options) { 162 | let {width, height, colorType, bytesPerPixel, data} = this 163 | return `ImageData ${inspect({width, height, colorType, bytesPerPixel, data}, options)}` 164 | } 165 | } 166 | 167 | function pixelSize(colorType){ 168 | const bpp = ["Alpha8", "Gray8", "R8UNorm"].includes(colorType) ? 1 169 | : ["A16Float", "A16UNorm", "ARGB4444", "R8G8UNorm", "RGB565"].includes(colorType) ? 2 170 | : [ "rgb", "rgba", "bgra", "BGR101010x", "BGRA1010102", "BGRA8888", "R16G16Float", "R16G16UNorm", 171 | "RGB101010x", "RGB888x", "RGBA1010102", "RGBA8888", "RGBA8888", "SRGBA8888" ].includes(colorType) ? 4 172 | : ["R16G16B16A16UNorm", "RGBAF16", "RGBAF16Norm"].includes(colorType) ? 8 173 | : colorType=="RGBAF32" ? 16 174 | : 0 175 | 176 | if (!bpp) throw new TypeError(`Unknown colorType: ${colorType}`) 177 | return bpp 178 | } 179 | 180 | module.exports = {Image, ImageData, loadImage, loadImageData, pixelSize} 181 | -------------------------------------------------------------------------------- /lib/classes/neon.js: -------------------------------------------------------------------------------- 1 | // 2 | // Neon <-> Node interface 3 | // 4 | 5 | "use strict" 6 | 7 | const {inspect} = require('util') 8 | 9 | const ø = Symbol.for('📦'), // the attr containing the boxed struct 10 | core = (obj) => (obj||{})[ø], // dereference the boxed struct 11 | wrap = (type, struct) => { // create new instance for struct 12 | let obj = internal(Object.create(type.prototype), ø, struct) 13 | return struct && internal(obj, 'native', neon[type.name]) 14 | }, 15 | neon = Object.entries(require('../v8')).reduce( (api, [name, fn]) => { 16 | let [_, struct, getset, attr] = name.match(/(.*?)_(?:([sg]et)_)?(.*)/), 17 | cls = api[struct] || (api[struct] = {}), 18 | slot = getset ? (cls[attr] || (cls[attr] = {})) : cls 19 | slot[getset || attr] = fn 20 | return api 21 | }, {}) 22 | 23 | class RustClass{ 24 | constructor(type){ 25 | internal(this, 'native', neon[type.name]) 26 | } 27 | 28 | alloc(...args){ 29 | return this.init('new', ...args) 30 | } 31 | 32 | init(fn, ...args){ 33 | return internal(this, ø, this.native[fn](null, ...args)) 34 | } 35 | 36 | ref(key, val){ 37 | return arguments.length > 1 ? this[Symbol.for(key)] = val : this[Symbol.for(key)] 38 | } 39 | 40 | prop(attr, ...vals){ 41 | let getset = arguments.length > 1 ? 'set' : 'get' 42 | return this.native[attr][getset](this[ø], ...vals) 43 | } 44 | 45 | ƒ(fn, ...args){ 46 | try{ 47 | return this.native[fn](this[ø], ...args) 48 | }catch(error){ 49 | Error.captureStackTrace(error, this.ƒ) 50 | throw error 51 | } 52 | } 53 | } 54 | 55 | // shorthands for attaching read-only attributes 56 | const readOnly = (obj, attr, value) => ( 57 | Object.defineProperty(obj, attr, {value, writable:false, enumerable:true}) 58 | ) 59 | 60 | const internal = (obj, attr, value) => ( 61 | Object.defineProperty(obj, attr, {value, writable:false, enumerable:false}) 62 | ) 63 | 64 | // convert arguments list to a string of type abbreviations 65 | function signature(args){ 66 | return args.map(v => (Array.isArray(v) ? 'a' : {string:'s', number:'n', object:'o'}[typeof v] || 'x')).join('') 67 | } 68 | 69 | module.exports = {neon, core, wrap, signature, readOnly, RustClass, inspect, REPR:inspect.custom} 70 | -------------------------------------------------------------------------------- /lib/classes/path.js: -------------------------------------------------------------------------------- 1 | // 2 | // Bézier paths 3 | // 4 | 5 | "use strict" 6 | 7 | const {RustClass, core, wrap, inspect, REPR} = require('./neon'), 8 | {toSkMatrix} = require('./geometry'), 9 | css = require('./css') 10 | 11 | class Path2D extends RustClass{ 12 | static op(operation, path, other){ 13 | return wrap(Path2D, path.ƒ("op", core(other), operation)) 14 | } 15 | 16 | static interpolate(path, other, weight){ 17 | return wrap(Path2D, path.ƒ("interpolate", core(other), weight)) 18 | } 19 | 20 | static effect(effect, path, ...args){ 21 | return wrap(Path2D, path.ƒ(effect, ...args)) 22 | } 23 | 24 | constructor(source){ 25 | super(Path2D) 26 | if (source instanceof Path2D) this.init('from_path', core(source)) 27 | else if (typeof source == 'string') this.init('from_svg', source) 28 | else this.alloc() 29 | } 30 | 31 | // dimensions & contents 32 | get bounds(){ return this.ƒ('bounds') } 33 | get edges(){ return this.ƒ("edges") } 34 | get d(){ return this.prop("d") } 35 | set d(svg){ return this.prop("d", svg) } 36 | contains(x, y){ return this.ƒ("contains", x, y)} 37 | 38 | points(step=1){ 39 | return this.jitter(step, 0).edges 40 | .map(([verb, ...pts]) => pts.slice(-2)) 41 | .filter(pt => pt.length) 42 | } 43 | 44 | // concatenation 45 | addPath(path, matrix){ 46 | if (!(path instanceof Path2D)) throw new Error("Expected a Path2D object") 47 | if (matrix) matrix = toSkMatrix(matrix) 48 | this.ƒ('addPath', core(path), matrix) 49 | } 50 | 51 | // line segments 52 | moveTo(x, y){ this.ƒ("moveTo", ...arguments) } 53 | lineTo(x, y){ this.ƒ("lineTo", ...arguments) } 54 | closePath(){ this.ƒ("closePath") } 55 | arcTo(x1, y1, x2, y2, radius){ this.ƒ("arcTo", ...arguments) } 56 | bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y){ this.ƒ("bezierCurveTo", ...arguments) } 57 | quadraticCurveTo(cpx, cpy, x, y){ this.ƒ("quadraticCurveTo", ...arguments) } 58 | conicCurveTo(cpx, cpy, x, y, weight){ this.ƒ("conicCurveTo", ...arguments) } 59 | 60 | // shape primitives 61 | ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, isCCW){ this.ƒ("ellipse", ...arguments) } 62 | rect(x, y, width, height){this.ƒ("rect", ...arguments) } 63 | arc(x, y, radius, startAngle, endAngle){ this.ƒ("arc", ...arguments) } 64 | roundRect(x, y, w, h, r){ 65 | let radii = css.radii(r) 66 | if (radii){ 67 | if (w < 0) radii = [radii[1], radii[0], radii[3], radii[2]] 68 | if (h < 0) radii = [radii[3], radii[2], radii[1], radii[0]] 69 | this.ƒ("roundRect", x, y, w, h, ...radii.map(({x, y}) => [x, y]).flat()) 70 | } 71 | } 72 | 73 | // tween similar paths 74 | interpolate(path, weight){ return Path2D.interpolate(this, path, weight) } 75 | 76 | // boolean operations 77 | complement(path){ return Path2D.op("complement", this, path) } 78 | difference(path){ return Path2D.op("difference", this, path) } 79 | intersect(path){ return Path2D.op("intersect", this, path) } 80 | union(path){ return Path2D.op("union", this, path) } 81 | xor(path){ return Path2D.op("xor", this, path) } 82 | 83 | // path effects 84 | jitter(len, amt, seed){ return Path2D.effect("jitter", this, ...arguments) } 85 | simplify(rule){ return Path2D.effect("simplify", this, rule) } 86 | unwind(){ return Path2D.effect("unwind", this) } 87 | round(radius){ return Path2D.effect("round", this, radius) } 88 | offset(dx, dy){ return Path2D.effect("offset", this, dx, dy) } 89 | 90 | transform(matrix){ 91 | return Path2D.effect("transform", this, toSkMatrix.apply(null, arguments)) 92 | } 93 | 94 | trim(...rng){ 95 | if (typeof rng[1] != 'number'){ 96 | if (rng[0] > 0) rng.unshift(0) 97 | else if (rng[0] < 0) rng.splice(1, 0, 1) 98 | } 99 | if (rng[0] < 0) rng[0] = Math.max(-1, rng[0]) + 1 100 | if (rng[1] < 0) rng[1] = Math.max(-1, rng[1]) + 1 101 | return Path2D.effect("trim", this, ...rng) 102 | } 103 | 104 | [REPR](depth, options) { 105 | let {d, bounds, edges} = this 106 | return `Path2D ${inspect({d, bounds, edges}, options)}` 107 | } 108 | } 109 | 110 | module.exports = {Path2D} 111 | -------------------------------------------------------------------------------- /lib/classes/typography.js: -------------------------------------------------------------------------------- 1 | // 2 | // Font management & metrics 3 | // 4 | 5 | "use strict" 6 | 7 | const {RustClass, readOnly, signature, inspect, REPR} = require('./neon'), 8 | {sync:globSync, hasMagic} = require('glob'), 9 | glob = paths => [paths].flat(2).map(pth => hasMagic(pth) ? globSync(pth) : pth).flat() 10 | 11 | class FontLibrary extends RustClass { 12 | constructor(){ 13 | super(FontLibrary) 14 | } 15 | 16 | get families(){ return this.prop('families') } 17 | 18 | has(familyName){ return this.ƒ('has', familyName) } 19 | 20 | family(name){ return this.ƒ('family', name) } 21 | 22 | use(...args){ 23 | let sig = signature(args) 24 | if (sig=='o'){ 25 | let results = {} 26 | for (let [alias, paths] of Object.entries(args.shift())){ 27 | results[alias] = this.ƒ("addFamily", alias, glob(paths)) 28 | } 29 | return results 30 | }else if (sig.match(/^s?[as]$/)){ 31 | let fonts = glob(args.pop()) 32 | let alias = args.shift() 33 | return this.ƒ("addFamily", alias, fonts) 34 | }else{ 35 | throw new Error("Expected an array of file paths or an object mapping family names to font files") 36 | } 37 | } 38 | 39 | reset(){ return this.ƒ('reset') } 40 | } 41 | 42 | class TextMetrics{ 43 | constructor([ 44 | left, right, ascent, descent, fontAscent, fontDescent, 45 | hanging, alphabetic, ideographic 46 | ], lines){ 47 | readOnly(this, "width", Math.max(0, right) + Math.max(0, left)) 48 | readOnly(this, "actualBoundingBoxLeft", left) 49 | readOnly(this, "actualBoundingBoxRight", right) 50 | readOnly(this, "actualBoundingBoxAscent", ascent) 51 | readOnly(this, "actualBoundingBoxDescent", descent) 52 | readOnly(this, "fontBoundingBoxAscent", fontAscent) 53 | readOnly(this, "fontBoundingBoxDescent", fontDescent) 54 | readOnly(this, "emHeightAscent", fontAscent) 55 | readOnly(this, "emHeightDescent", fontDescent) 56 | readOnly(this, "hangingBaseline", hanging) 57 | readOnly(this, "alphabeticBaseline", alphabetic) 58 | readOnly(this, "ideographicBaseline", ideographic) 59 | readOnly(this, "lines", lines.map( ([x, y, width, height, baseline, startIndex, endIndex]) => ( 60 | {x, y, width, height, baseline, startIndex, endIndex} 61 | ))) 62 | } 63 | } 64 | 65 | 66 | module.exports = {FontLibrary:new FontLibrary(), TextMetrics} 67 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // 2 | // Skia Canvas — CommonJS version 3 | // 4 | 5 | "use strict" 6 | 7 | const {Canvas, CanvasGradient, CanvasPattern, CanvasTexture} = require('./classes/canvas'), 8 | {Image, ImageData, loadImage, loadImageData} = require('./classes/imagery'), 9 | {DOMPoint, DOMMatrix, DOMRect} = require('./classes/geometry'), 10 | {TextMetrics, FontLibrary} = require('./classes/typography'), 11 | {CanvasRenderingContext2D} = require('./classes/context'), 12 | {App, Window} = require('./classes/gui'), 13 | {Path2D} = require('./classes/path') 14 | 15 | module.exports = { 16 | Canvas, CanvasGradient, CanvasPattern, CanvasTexture, 17 | Image, ImageData, loadImage, loadImageData, 18 | Path2D, DOMPoint, DOMMatrix, DOMRect, 19 | FontLibrary, TextMetrics, 20 | CanvasRenderingContext2D, 21 | App, Window, 22 | } 23 | -------------------------------------------------------------------------------- /lib/index.mjs: -------------------------------------------------------------------------------- 1 | // 2 | // Skia Canvas — ES Module version 3 | // 4 | 5 | import skia_canvas from './index.js' 6 | 7 | const { 8 | Canvas, CanvasGradient, CanvasPattern, CanvasTexture, 9 | Image, ImageData, loadImage, loadImageData, 10 | Path2D, DOMPoint, DOMMatrix, DOMRect, 11 | FontLibrary, TextMetrics, 12 | CanvasRenderingContext2D, 13 | App, Window, 14 | } = skia_canvas 15 | 16 | export { 17 | skia_canvas as default, 18 | Canvas, CanvasGradient, CanvasPattern, CanvasTexture, 19 | Image, ImageData, loadImage, loadImageData, 20 | Path2D, DOMPoint, DOMMatrix, DOMRect, 21 | FontLibrary, TextMetrics, 22 | CanvasRenderingContext2D, 23 | App, Window, 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skia-canvas", 3 | "version": "2.0.2", 4 | "description": "A GPU-accelerated Canvas Graphics API for Node", 5 | "author": "Christian Swinehart ", 6 | "license": "MIT", 7 | "homepage": "https://skia-canvas.org", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/samizdatco/skia-canvas.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/samizdatco/skia-canvas/issues" 14 | }, 15 | "main": "./lib/index.js", 16 | "exports": { 17 | "node": { 18 | "import": "./lib/index.mjs", 19 | "require": "./lib/index.js" 20 | }, 21 | "browser": "./lib/browser.js", 22 | "types": "./lib/index.d.ts" 23 | }, 24 | "browser": { 25 | "path": "path-browserify" 26 | }, 27 | "scripts": { 28 | "build": "cargo-cp-artifact -nc lib/v8/index.node -- cargo build --message-format=json-render-diagnostics", 29 | "install": "node-pre-gyp install || npm run build -- --release", 30 | "package": "node-pre-gyp package", 31 | "upload": "gh release upload v$npm_package_version build/stage/v$npm_package_version/*", 32 | "test": "jest" 33 | }, 34 | "dependencies": { 35 | "@mapbox/node-pre-gyp": "^1.0.11", 36 | "cargo-cp-artifact": "^0.1", 37 | "glob": "^11.0.0", 38 | "path-browserify": "^1.0.1", 39 | "simple-get": "^4.0.1", 40 | "string-split-by": "^1.0.0" 41 | }, 42 | "devDependencies": { 43 | "@types/jest": "^29.5.14", 44 | "@types/lodash": "^4.17.13", 45 | "@types/node": "^22.10.1", 46 | "express": "^4.21.2", 47 | "jest": "^29.7.0", 48 | "lodash": "^4.17.21", 49 | "nodemon": "^3.1.7", 50 | "tmp": "^0.2.3" 51 | }, 52 | "files": [ 53 | "lib" 54 | ], 55 | "binary": { 56 | "module_name": "index", 57 | "module_path": "./lib/v8", 58 | "remote_path": "./v{version}", 59 | "package_name": "{platform}-{arch}-{libc}.tar.gz", 60 | "host": "https://github.com/samizdatco/skia-canvas/releases/download" 61 | }, 62 | "keywords": [ 63 | "canvas", 64 | "gpu", 65 | "skia", 66 | "offscreen", 67 | "headless", 68 | "graphic", 69 | "graphics", 70 | "image", 71 | "images", 72 | "compositing", 73 | "render", 74 | "vulkan", 75 | "metal", 76 | "pdf", 77 | "svg", 78 | "rust" 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /src/canvas.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | use std::cell::RefCell; 3 | use neon::prelude::*; 4 | 5 | use crate::utils::*; 6 | use crate::context::page::pages_arg; 7 | use crate::gpu; 8 | 9 | pub type BoxedCanvas = JsBox>; 10 | impl Finalize for Canvas {} 11 | 12 | pub struct Canvas{ 13 | pub width: f32, 14 | pub height: f32, 15 | engine: Option, 16 | } 17 | 18 | impl Canvas{ 19 | pub fn new() -> Self{ 20 | Canvas{width:300.0, height:150.0, engine:None} 21 | } 22 | 23 | pub fn engine(&mut self) -> gpu::RenderingEngine{ 24 | self.engine.get_or_insert_with(|| 25 | gpu::RenderingEngine::default() 26 | ).clone() 27 | } 28 | } 29 | 30 | // 31 | // -- Javascript Methods -------------------------------------------------------------------------- 32 | // 33 | 34 | pub fn new(mut cx: FunctionContext) -> JsResult { 35 | let this = RefCell::new(Canvas::new()); 36 | Ok(cx.boxed(this)) 37 | } 38 | 39 | pub fn get_width(mut cx: FunctionContext) -> JsResult { 40 | let this = cx.argument::(0)?; 41 | let width = this.borrow().width; 42 | Ok(cx.number(width as f64)) 43 | } 44 | 45 | pub fn get_height(mut cx: FunctionContext) -> JsResult { 46 | let this = cx.argument::(0)?; 47 | let height = this.borrow().height; 48 | Ok(cx.number(height as f64)) 49 | } 50 | 51 | pub fn set_width(mut cx: FunctionContext) -> JsResult { 52 | let this = cx.argument::(0)?; 53 | let width = float_arg(&mut cx, 1, "size")?; 54 | this.borrow_mut().width = width; 55 | Ok(cx.undefined()) 56 | } 57 | 58 | pub fn set_height(mut cx: FunctionContext) -> JsResult { 59 | let this = cx.argument::(0)?; 60 | let height = float_arg(&mut cx, 1, "size")?; 61 | this.borrow_mut().height = height; 62 | Ok(cx.undefined()) 63 | } 64 | 65 | pub fn get_engine(mut cx: FunctionContext) -> JsResult { 66 | let this = cx.argument::(0)?; 67 | let mut this = this.borrow_mut(); 68 | Ok(cx.string(from_engine(this.engine()))) 69 | } 70 | 71 | pub fn set_engine(mut cx: FunctionContext) -> JsResult { 72 | let this = cx.argument::(0)?; 73 | if let Some(engine_name) = opt_string_arg(&mut cx, 1){ 74 | if let Some(new_engine) = to_engine(&engine_name){ 75 | if new_engine.selectable() { 76 | this.borrow_mut().engine = Some(new_engine) 77 | } 78 | } 79 | } 80 | 81 | Ok(cx.undefined()) 82 | } 83 | 84 | pub fn get_engine_status(mut cx: FunctionContext) -> JsResult { 85 | let this = cx.argument::(0)?; 86 | let mut this = this.borrow_mut(); 87 | 88 | let details = this.engine().status(); 89 | Ok(cx.string(details.to_string())) 90 | } 91 | 92 | pub fn toBuffer(mut cx: FunctionContext) -> JsResult { 93 | let this = cx.argument::(0)?; 94 | let pages = pages_arg(&mut cx, 1, &this)?; 95 | let options = export_options_arg(&mut cx, 2)?; 96 | 97 | let channel = cx.channel(); 98 | let (deferred, promise) = cx.promise(); 99 | rayon::spawn(move || { 100 | let result = { 101 | if options.format=="pdf" && pages.len() > 1 { 102 | pages.as_pdf(options) 103 | }else{ 104 | pages.first().encoded_as(options, pages.engine) 105 | } 106 | }; 107 | 108 | deferred.settle_with(&channel, move |mut cx| { 109 | let data = result.or_else(|err| cx.throw_error(err))?; 110 | let buffer = JsBuffer::from_slice(&mut cx, data.as_bytes())?; 111 | Ok(buffer) 112 | }); 113 | }); 114 | 115 | Ok(promise) 116 | } 117 | 118 | pub fn toBufferSync(mut cx: FunctionContext) -> JsResult { 119 | let this = cx.argument::(0)?; 120 | let pages = pages_arg(&mut cx, 1, &this)?; 121 | let options = export_options_arg(&mut cx, 2)?; 122 | 123 | let encoded = { 124 | if options.format=="pdf" && pages.len() > 1 { 125 | pages.as_pdf(options) 126 | }else{ 127 | pages.first().encoded_as(options, pages.engine) 128 | } 129 | }; 130 | 131 | match encoded{ 132 | Ok(data) => { 133 | let buffer = JsBuffer::from_slice(&mut cx, data.as_bytes())?; 134 | Ok(buffer.upcast::()) 135 | }, 136 | Err(msg) => cx.throw_error(msg) 137 | } 138 | } 139 | 140 | pub fn save(mut cx: FunctionContext) -> JsResult { 141 | let this = cx.argument::(0)?; 142 | let pages = pages_arg(&mut cx, 1, &this)?; 143 | let name_pattern = string_arg(&mut cx, 2, "filePath")?; 144 | let sequence = !cx.argument::(3)?.is_a::(&mut cx); 145 | let padding = opt_float_arg(&mut cx, 3).unwrap_or(-1.0); 146 | let options = export_options_arg(&mut cx, 4)?; 147 | 148 | let channel = cx.channel(); 149 | let (deferred, promise) = cx.promise(); 150 | rayon::spawn(move || { 151 | let result = { 152 | if sequence { 153 | pages.write_sequence(&name_pattern, padding, options) 154 | } else if options.format == "pdf" { 155 | pages.write_pdf(&name_pattern, options) 156 | } else { 157 | pages.write_image(&name_pattern, options) 158 | } 159 | }; 160 | 161 | deferred.settle_with(&channel, move |mut cx| match result{ 162 | Err(msg) => cx.throw_error(format!("I/O Error: {}", msg)), 163 | _ => Ok(cx.undefined()) 164 | }); 165 | }); 166 | 167 | Ok(promise) 168 | } 169 | 170 | pub fn saveSync(mut cx: FunctionContext) -> JsResult { 171 | let this = cx.argument::(0)?; 172 | let pages = pages_arg(&mut cx, 1, &this)?; 173 | let name_pattern = string_arg(&mut cx, 2, "filePath")?; 174 | let sequence = !cx.argument::(3)?.is_a::(&mut cx); 175 | let padding = opt_float_arg(&mut cx, 3).unwrap_or(-1.0); 176 | let options = export_options_arg(&mut cx, 4)?; 177 | 178 | let result = { 179 | if sequence { 180 | pages.write_sequence(&name_pattern, padding, options) 181 | } else if options.format == "pdf" { 182 | pages.write_pdf(&name_pattern, options) 183 | } else { 184 | pages.write_image(&name_pattern, options) 185 | } 186 | }; 187 | 188 | match result{ 189 | Ok(_) => Ok(cx.undefined()), 190 | Err(msg) => cx.throw_error(msg) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/filter.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_mut)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | #![allow(dead_code)] 5 | use std::fmt; 6 | use skia_safe::{Paint, Matrix, Point, Color, MaskFilter, ImageFilter as SkImageFilter, 7 | BlurStyle, FilterMode, MipmapMode, SamplingOptions, TileMode, ColorSpace, 8 | image_filters, color_filters, table_color_filter}; 9 | 10 | use crate::utils::*; 11 | 12 | #[derive(Clone, Debug)] 13 | pub enum FilterSpec{ 14 | Plain{name:String, value:f32}, 15 | Shadow{offset:Point, blur:f32, color:Color}, 16 | } 17 | 18 | #[derive(Clone, Debug)] 19 | pub struct Filter { 20 | pub css: String, 21 | specs: Vec, 22 | _raster: Option, 23 | _vector: Option 24 | } 25 | 26 | #[derive(Clone, Debug)] 27 | pub struct LastFilter { 28 | matrix: Matrix, 29 | mask: Option, 30 | image: Option 31 | } 32 | 33 | impl LastFilter { 34 | fn match_scale(&self, matrix:Matrix) -> Option { 35 | if self.matrix.scale_x() == matrix.scale_x() && self.matrix.scale_y() == matrix.scale_y(){ 36 | Some(self.clone()) 37 | }else{ 38 | None 39 | } 40 | } 41 | } 42 | 43 | impl Default for Filter{ 44 | fn default() -> Self { 45 | Filter{ css:"none".to_string(), specs:vec![], _raster:None, _vector:None } 46 | } 47 | } 48 | 49 | impl fmt::Display for Filter { 50 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 51 | write!(f, "{}", self.css) 52 | } 53 | } 54 | 55 | impl Filter { 56 | pub fn new(css:&str, specs:&[FilterSpec]) -> Self { 57 | let css = css.to_string(); 58 | let specs = specs.to_vec(); 59 | Filter{ css, specs, _raster:None, _vector:None } 60 | } 61 | 62 | pub fn mix_into<'a>(&mut self, paint:&'a mut Paint, matrix:Matrix, raster:bool) -> &'a mut Paint { 63 | let filters = self.filters_for(matrix, raster); 64 | paint.set_image_filter(filters.image) 65 | .set_mask_filter(filters.mask) 66 | } 67 | 68 | fn filters_for(&mut self, matrix:Matrix, raster:bool) -> LastFilter { 69 | let cached = match (raster, &self._raster, &self._vector) { 70 | (true, Some(cached), _) | (false, _, Some(cached)) => cached.match_scale(matrix), 71 | _ => None 72 | }; 73 | 74 | cached.or_else(|| { 75 | let mut mask_filter = None; 76 | let image_filter = self.specs.iter().fold(None, |chain, next_filter| 77 | match next_filter { 78 | FilterSpec::Shadow{ offset, blur, color } => { 79 | let scale = Point{x:matrix.scale_x(), y:matrix.scale_y()}; 80 | let point = (offset.x / scale.x, offset.y / scale.y); 81 | let sigma = ( blur / scale.x, blur / scale.y); 82 | image_filters::drop_shadow(point, sigma, *color, ColorSpace::new_srgb(), chain, None) 83 | }, 84 | FilterSpec::Plain{ name, value } => match name.as_ref() { 85 | "blur" => { 86 | if raster { 87 | let sigma_x = value / (2.0 * matrix.scale_x()); 88 | let sigma_y = value / (2.0 * matrix.scale_y()); 89 | image_filters::blur((sigma_x, sigma_y), TileMode::Decal, chain, None) 90 | } else { 91 | mask_filter = MaskFilter::blur(BlurStyle::Normal, *value, false); 92 | chain 93 | } 94 | }, 95 | 96 | // 97 | // matrices and formulæ taken from: https://www.w3.org/TR/filter-effects-1/ 98 | // 99 | "brightness" => { 100 | let amt = value.max(0.0); 101 | let color_matrix = color_filters::matrix_row_major(&[ 102 | amt, 0.0, 0.0, 0.0, 0.0, 103 | 0.0, amt, 0.0, 0.0, 0.0, 104 | 0.0, 0.0, amt, 0.0, 0.0, 105 | 0.0, 0.0, 0.0, 1.0, 0.0 106 | ], None); 107 | image_filters::color_filter(color_matrix, chain, None) 108 | }, 109 | "contrast" => { 110 | let amt = value.max(0.0); 111 | let mut ramp = [0u8; 256]; 112 | for (i, val) in ramp.iter_mut().take(256).enumerate() { 113 | let orig = i as f32; 114 | *val = (127.0 + amt * orig - (127.0 * amt )) as u8; 115 | } 116 | let table = Some(&ramp); 117 | if let Some(color_table) = color_filters::table_argb(None, table, table, table){ 118 | image_filters::color_filter(color_table, chain, None) 119 | }else{ 120 | chain 121 | } 122 | }, 123 | "grayscale" => { 124 | let amt = 1.0 - value.clamp(0.0, 1.0); 125 | let color_matrix = color_filters::matrix_row_major(&[ 126 | (0.2126 + 0.7874 * amt), (0.7152 - 0.7152 * amt), (0.0722 - 0.0722 * amt), 0.0, 0.0, 127 | (0.2126 - 0.2126 * amt), (0.7152 + 0.2848 * amt), (0.0722 - 0.0722 * amt), 0.0, 0.0, 128 | (0.2126 - 0.2126 * amt), (0.7152 - 0.7152 * amt), (0.0722 + 0.9278 * amt), 0.0, 0.0, 129 | 0.0, 0.0, 0.0, 1.0, 0.0 130 | ], None); 131 | image_filters::color_filter(color_matrix, chain, None) 132 | }, 133 | "invert" => { 134 | let amt = value.clamp(0.0, 1.0); 135 | let mut ramp = [0u8; 256]; 136 | for (i, val) in ramp.iter_mut().take(256).enumerate().map(|(i,v)| (i as f32, v)) { 137 | let (orig, inv) = (i, 255.0-i); 138 | *val = (orig * (1.0 - amt) + inv * amt) as u8; 139 | } 140 | let table = Some(&ramp); 141 | if let Some(color_table) = color_filters::table_argb(None, table, table, table){ 142 | image_filters::color_filter(color_table, chain, None) 143 | }else{ 144 | chain 145 | } 146 | }, 147 | "opacity" => { 148 | let amt = value.clamp(0.0, 1.0); 149 | let color_matrix = color_filters::matrix_row_major(&[ 150 | 1.0, 0.0, 0.0, 0.0, 0.0, 151 | 0.0, 1.0, 0.0, 0.0, 0.0, 152 | 0.0, 0.0, 1.0, 0.0, 0.0, 153 | 0.0, 0.0, 0.0, amt, 0.0 154 | ], None); 155 | image_filters::color_filter(color_matrix, chain, None) 156 | }, 157 | "saturate" => { 158 | let amt = value.max(0.0); 159 | let color_matrix = color_filters::matrix_row_major(&[ 160 | (0.2126 + 0.7874 * amt), (0.7152 - 0.7152 * amt), (0.0722 - 0.0722 * amt), 0.0, 0.0, 161 | (0.2126 - 0.2126 * amt), (0.7152 + 0.2848 * amt), (0.0722 - 0.0722 * amt), 0.0, 0.0, 162 | (0.2126 - 0.2126 * amt), (0.7152 - 0.7152 * amt), (0.0722 + 0.9278 * amt), 0.0, 0.0, 163 | 0.0, 0.0, 0.0, 1.0, 0.0 164 | ], None); 165 | image_filters::color_filter(color_matrix, chain, None) 166 | }, 167 | "sepia" => { 168 | let amt = 1.0 - value.clamp(0.0, 1.0); 169 | let color_matrix = color_filters::matrix_row_major(&[ 170 | (0.393 + 0.607 * amt), (0.769 - 0.769 * amt), (0.189 - 0.189 * amt), 0.0, 0.0, 171 | (0.349 - 0.349 * amt), (0.686 + 0.314 * amt), (0.168 - 0.168 * amt), 0.0, 0.0, 172 | (0.272 - 0.272 * amt), (0.534 - 0.534 * amt), (0.131 + 0.869 * amt), 0.0, 0.0, 173 | 0.0, 0.0, 0.0, 1.0, 0.0 174 | ], None); 175 | image_filters::color_filter(color_matrix, chain, None) 176 | }, 177 | "hue-rotate" => { 178 | let cos = to_radians(*value).cos(); 179 | let sin = to_radians(*value).sin(); 180 | let color_matrix = color_filters::matrix_row_major(&[ 181 | (0.213 + cos*0.787 - sin*0.213), (0.715 - cos*0.715 - sin*0.715), (0.072 - cos*0.072 + sin*0.928), 0.0, 0.0, 182 | (0.213 - cos*0.213 + sin*0.143), (0.715 + cos*0.285 + sin*0.140), (0.072 - cos*0.072 - sin*0.283), 0.0, 0.0, 183 | (0.213 - cos*0.213 - sin*0.787), (0.715 - cos*0.715 + sin*0.715), (0.072 + cos*0.928 + sin*0.072), 0.0, 0.0, 184 | 0.0, 0.0, 0.0, 1.0, 0.0 185 | ], None); 186 | image_filters::color_filter(color_matrix, chain, None) 187 | }, 188 | _ => chain 189 | } 190 | } 191 | ); 192 | 193 | let filters = Some(LastFilter{matrix, mask:mask_filter, image:image_filter}); 194 | if raster{ self._raster = filters.clone(); } 195 | else{ self._vector = filters.clone(); } 196 | filters 197 | }).expect("Could not create filter") 198 | } 199 | } 200 | 201 | #[derive(Copy, Clone)] 202 | pub enum FilterQuality{ 203 | None, Low, Medium, High 204 | } 205 | 206 | #[derive(Copy, Clone)] 207 | pub struct ImageFilter { 208 | pub smoothing: bool, 209 | pub quality: FilterQuality 210 | } 211 | 212 | impl ImageFilter { 213 | pub fn sampling(&self) -> SamplingOptions { 214 | let quality = if self.smoothing { self.quality } else { FilterQuality::None }; 215 | match quality { 216 | FilterQuality::None => SamplingOptions::new(FilterMode::Nearest, MipmapMode::None), 217 | FilterQuality::Low => SamplingOptions::new(FilterMode::Linear, MipmapMode::Nearest), 218 | FilterQuality::Medium => SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear), 219 | FilterQuality::High => SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear) 220 | } 221 | } 222 | 223 | } 224 | -------------------------------------------------------------------------------- /src/gpu/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::upper_case_acronyms)] 2 | use skia_safe::{ImageInfo, Surface, surfaces}; 3 | use serde_json::Value; 4 | 5 | #[cfg(feature = "metal")] 6 | mod metal; 7 | #[cfg(feature = "metal")] 8 | use crate::gpu::metal::MetalEngine as Engine; 9 | #[cfg(all(feature = "metal", feature = "window"))] 10 | pub use crate::gpu::metal::MetalRenderer as Renderer; 11 | 12 | 13 | #[cfg(feature = "vulkan")] 14 | mod vulkan; 15 | #[cfg(feature = "vulkan")] 16 | use crate::gpu::vulkan::VulkanEngine as Engine; 17 | #[cfg(all(feature = "vulkan", feature = "window"))] 18 | pub use crate::gpu::vulkan::VulkanRenderer as Renderer; 19 | 20 | #[cfg(not(any(feature = "vulkan", feature = "metal")))] 21 | struct Engine { } 22 | #[cfg(not(any(feature = "vulkan", feature = "metal")))] 23 | impl Engine { 24 | pub fn supported() -> bool { false } 25 | pub fn with_surface(_: &ImageInfo, _:Option, _:F) -> Result 26 | where F:FnOnce(&mut Surface) -> Result 27 | { 28 | Err("Compiled without GPU support".to_string()) 29 | } 30 | pub fn status() -> Value { serde_json::json!({ 31 | "renderer": "CPU", 32 | "api": Value::Null, 33 | "device": "CPU-based renderer (compiled without GPU support)", 34 | "error": Value::Null, 35 | })} 36 | } 37 | 38 | #[derive(Copy, Clone, Debug)] 39 | pub enum RenderingEngine{ 40 | CPU, 41 | GPU, 42 | } 43 | 44 | impl Default for RenderingEngine { 45 | fn default() -> Self { 46 | if Engine::supported() { Self::GPU } else { Self::CPU } 47 | } 48 | } 49 | 50 | #[allow(dead_code)] 51 | impl RenderingEngine{ 52 | pub fn selectable(&self) -> bool { 53 | match self { 54 | Self::GPU => Engine::supported(), 55 | Self::CPU => true 56 | } 57 | } 58 | 59 | pub fn with_surface(&self, image_info: &ImageInfo, msaa:Option, f:F) -> Result 60 | where F:FnOnce(&mut Surface) -> Result 61 | { 62 | match self { 63 | Self::GPU => Engine::with_surface(image_info, msaa, f), 64 | Self::CPU => surfaces::raster(image_info, None, None) 65 | .ok_or(format!("Could not allocate new {}×{} bitmap", image_info.width(), image_info.height())) 66 | .and_then(|mut surface|f(&mut surface)) 67 | } 68 | } 69 | 70 | pub fn status(&self) -> serde_json::Value { 71 | let mut status = Engine::status(); 72 | if let Self::CPU = self{ 73 | if Engine::supported(){ 74 | status["renderer"] = Value::String("CPU".to_string()); 75 | status["device"] = Value::String("CPU-based renderer (GPU manually disabled)".to_string()) 76 | } 77 | } 78 | status 79 | } 80 | 81 | pub fn lacks_gpu_support(&self) -> Option { 82 | match Engine::supported(){ 83 | true => None, 84 | false => { 85 | let mut msg = vec!["No windowing support".to_string()]; 86 | if let Some(Value::String(error)) = Engine::status().get("error"){ 87 | msg.push(error.to_string()); 88 | } 89 | Some(msg.join(": ")) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/gpu/vulkan/mod.rs: -------------------------------------------------------------------------------- 1 | use vulkano::format::Format as VkFormat; 2 | use skia_safe::{ gpu::vk, ColorType }; 3 | 4 | pub mod engine; 5 | pub use engine::VulkanEngine; 6 | 7 | pub mod renderer; 8 | pub use renderer::VulkanRenderer; 9 | 10 | static VK_FORMATS: &'static [VkFormat] = &[ 11 | VkFormat::R8G8B8A8_UNORM, 12 | VkFormat::R8G8B8A8_SRGB, 13 | VkFormat::R8_UNORM, 14 | VkFormat::B8G8R8A8_UNORM, 15 | VkFormat::R5G6B5_UNORM_PACK16, 16 | VkFormat::B5G6R5_UNORM_PACK16, 17 | VkFormat::R16G16B16A16_SFLOAT, 18 | VkFormat::R16_SFLOAT, 19 | VkFormat::R8G8B8_UNORM, 20 | VkFormat::R8G8_UNORM, 21 | VkFormat::A2B10G10R10_UNORM_PACK32, 22 | VkFormat::A2R10G10B10_UNORM_PACK32, 23 | VkFormat::R10X6G10X6B10X6A10X6_UNORM_4PACK16, 24 | VkFormat::B4G4R4A4_UNORM_PACK16, 25 | VkFormat::R4G4B4A4_UNORM_PACK16, 26 | VkFormat::R16_UNORM, 27 | VkFormat::R16G16_UNORM, 28 | VkFormat::G8_B8_R8_3PLANE_420_UNORM, 29 | VkFormat::G8_B8R8_2PLANE_420_UNORM, 30 | VkFormat::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, 31 | VkFormat::R16G16B16A16_UNORM, 32 | VkFormat::R16G16_SFLOAT, 33 | ]; 34 | 35 | fn to_sk_format(vulkano_format:&VkFormat) -> Option<(vk::Format, ColorType)>{ 36 | // Format / ColorType pairs 37 | // https://github.com/google/skia/blob/4f24819404272433687a76e407bcd7877384f512/src/gpu/ganesh/vk/GrVkCaps.cpp#L880 38 | // 39 | // GrColorType -> SkColorType mappings 40 | // https://github.com/google/skia/blob/4f24819404272433687a76e407bcd7877384f512/include/private/gpu/ganesh/GrTypesPriv.h#L590 41 | // 42 | // Present in the GrVkCaps 'supported' list but lacking supported GrColorTypes so omitted: 43 | // - VkFormat::ETC2_R8G8B8_UNORM_BLOCK 44 | // - VkFormat::BC1_RGB_UNORM_BLOCK 45 | // - VkFormat::BC1_RGBA_UNORM_BLOCK 46 | match vulkano_format { 47 | VkFormat::R8G8B8A8_UNORM => Some(( vk::Format::R8G8B8A8_UNORM, ColorType::RGBA8888 )), 48 | VkFormat::R8G8B8A8_SRGB => Some(( vk::Format::R8G8B8A8_SRGB, ColorType::SRGBA8888 )), 49 | VkFormat::R8_UNORM => Some(( vk::Format::R8_UNORM, ColorType::R8UNorm )), 50 | VkFormat::B8G8R8A8_UNORM => Some(( vk::Format::B8G8R8A8_UNORM, ColorType::BGRA8888 )), 51 | VkFormat::R5G6B5_UNORM_PACK16 => Some(( vk::Format::R5G6B5_UNORM_PACK16, ColorType::RGB565 )), 52 | VkFormat::B5G6R5_UNORM_PACK16 => Some(( vk::Format::B5G6R5_UNORM_PACK16, ColorType::RGB565 )), 53 | VkFormat::R16G16B16A16_SFLOAT => Some(( vk::Format::R16G16B16A16_SFLOAT, ColorType::RGBAF16 )), 54 | VkFormat::R16_SFLOAT => Some(( vk::Format::R16_SFLOAT, ColorType::A16Float )), 55 | VkFormat::R8G8B8_UNORM => Some(( vk::Format::R8G8B8_UNORM, ColorType::RGB888x )), 56 | VkFormat::R8G8_UNORM => Some(( vk::Format::R8G8_UNORM, ColorType::R8G8UNorm )), 57 | VkFormat::A2B10G10R10_UNORM_PACK32 => Some(( vk::Format::A2B10G10R10_UNORM_PACK32, ColorType::RGBA1010102 )), 58 | VkFormat::A2R10G10B10_UNORM_PACK32 => Some(( vk::Format::A2R10G10B10_UNORM_PACK32, ColorType::BGRA1010102 )), 59 | VkFormat::R10X6G10X6B10X6A10X6_UNORM_4PACK16 => Some(( vk::Format::R10X6G10X6B10X6A10X6_UNORM_4PACK16, ColorType::RGBA10x6 )), 60 | VkFormat::B4G4R4A4_UNORM_PACK16 => Some(( vk::Format::B4G4R4A4_UNORM_PACK16, ColorType::ARGB4444 )), 61 | VkFormat::R4G4B4A4_UNORM_PACK16 => Some(( vk::Format::R4G4B4A4_UNORM_PACK16, ColorType::ARGB4444 )), 62 | VkFormat::R16_UNORM => Some(( vk::Format::R16_UNORM, ColorType::A16UNorm )), 63 | VkFormat::R16G16_UNORM => Some(( vk::Format::R16G16_UNORM, ColorType::R16G16UNorm )), 64 | VkFormat::G8_B8_R8_3PLANE_420_UNORM => Some(( vk::Format::G8_B8_R8_3PLANE_420_UNORM, ColorType::RGB888x )), 65 | VkFormat::G8_B8R8_2PLANE_420_UNORM => Some(( vk::Format::G8_B8R8_2PLANE_420_UNORM, ColorType::RGB888x )), 66 | VkFormat::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16 => Some(( vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, ColorType::RGBA1010102 )), 67 | VkFormat::R16G16B16A16_UNORM => Some(( vk::Format::R16G16B16A16_UNORM, ColorType::R16G16B16A16UNorm )), 68 | VkFormat::R16G16_SFLOAT => Some(( vk::Format::R16G16_SFLOAT, ColorType::R16G16Float )), 69 | _ => None 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/gradient.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(non_snake_case)] 3 | use std::cell::RefCell; 4 | use std::sync::{Arc, Mutex}; 5 | use neon::prelude::*; 6 | use skia_safe::{Shader, Color, Point, TileMode, Matrix}; 7 | use skia_safe::{gradient_shader, gradient_shader::GradientShaderColors::Colors}; 8 | 9 | use crate::utils::*; 10 | 11 | enum Gradient{ 12 | Linear{ 13 | start:Point, 14 | end:Point, 15 | stops:Vec, 16 | colors:Vec, 17 | }, 18 | Radial{ 19 | start_point:Point, 20 | start_radius:f32, 21 | end_point:Point, 22 | end_radius:f32, 23 | stops:Vec, 24 | colors:Vec, 25 | }, 26 | Conic{ 27 | center:Point, 28 | angle:f32, 29 | stops:Vec, 30 | colors:Vec, 31 | } 32 | } 33 | 34 | pub type BoxedCanvasGradient = JsBox>; 35 | impl Finalize for CanvasGradient {} 36 | 37 | #[derive(Clone)] 38 | pub struct CanvasGradient{ 39 | gradient:Arc> 40 | } 41 | 42 | impl CanvasGradient{ 43 | pub fn shader(&self) -> Option{ 44 | 45 | let gradient = Arc::clone(&self.gradient); 46 | let gradient = gradient.lock().unwrap(); 47 | 48 | match &*gradient{ 49 | Gradient::Linear{start, end, stops, colors} => { 50 | gradient_shader::linear((*start, *end), Colors(colors), Some(stops.as_slice()), TileMode::Clamp, None, None) 51 | }, 52 | Gradient::Radial{start_point, start_radius, end_point, end_radius, stops, colors} => { 53 | gradient_shader::two_point_conical( 54 | *start_point, *start_radius, 55 | *end_point, *end_radius, 56 | Colors(colors), Some(stops.as_slice()), 57 | TileMode::Clamp, None, None) 58 | }, 59 | Gradient::Conic{center, angle, stops, colors} => { 60 | let Point{x, y} = *center; 61 | let mut rotated = Matrix::new_identity(); 62 | rotated 63 | .pre_translate((x, y)) 64 | .pre_rotate(*angle, None) 65 | .pre_translate((-x, -y)); 66 | 67 | gradient_shader::sweep( 68 | *center, 69 | Colors(colors), 70 | Some(stops.as_slice()), 71 | TileMode::Clamp, 72 | None, // angles 73 | None, // flags 74 | Some(&rotated), // local_matrix 75 | 76 | ) 77 | } 78 | } 79 | } 80 | 81 | pub fn add_color_stop(&mut self, offset: f32, color:Color){ 82 | // let gradient = &mut *self.gradient.borrow_mut(); 83 | let gradient = Arc::clone(&self.gradient); 84 | let mut gradient = gradient.lock().unwrap(); 85 | 86 | let stops = match &*gradient{ 87 | Gradient::Linear{stops, ..} => stops, 88 | Gradient::Radial{stops, ..} => stops, 89 | Gradient::Conic{stops, ..} => stops, 90 | }; 91 | 92 | // insert the new entries at the right index to keep the vectors sorted 93 | let idx = stops.binary_search_by(|n| (n-f32::EPSILON).partial_cmp(&offset).unwrap()).unwrap_or_else(|x| x); 94 | match &mut *gradient{ 95 | Gradient::Linear{colors, stops, ..} => { colors.insert(idx, color); stops.insert(idx, offset); }, 96 | Gradient::Radial{colors, stops, ..} => { colors.insert(idx, color); stops.insert(idx, offset); }, 97 | Gradient::Conic{colors, stops, ..} => { colors.insert(idx, color); stops.insert(idx, offset); }, 98 | }; 99 | } 100 | } 101 | 102 | // 103 | // -- Javascript Methods -------------------------------------------------------------------------- 104 | // 105 | 106 | pub fn linear(mut cx: FunctionContext) -> JsResult { 107 | if let [x1, y1, x2, y2] = opt_float_args(&mut cx, 1..5).as_slice(){ 108 | let start = Point::new(*x1, *y1); 109 | let end = Point::new(*x2, *y2); 110 | let ramp = Gradient::Linear{ start, end, stops:vec![], colors:vec![] }; 111 | let canvas_gradient = CanvasGradient{ gradient:Arc::new(Mutex::new(ramp)) }; 112 | let this = RefCell::new(canvas_gradient); 113 | Ok(cx.boxed(this)) 114 | }else{ 115 | let msg = format!("Expected 4 arguments (x1, y1, x2, y2), received {}", cx.len() - 1); 116 | cx.throw_type_error(msg) 117 | } 118 | } 119 | 120 | pub fn radial(mut cx: FunctionContext) -> JsResult { 121 | if let [x1, y1, r1, x2, y2, r2] = opt_float_args(&mut cx, 1..7).as_slice(){ 122 | let start_point = Point::new(*x1, *y1); 123 | let end_point = Point::new(*x2, *y2); 124 | let bloom = Gradient::Radial{ start_point, start_radius:*r1, end_point, end_radius:*r2, stops:vec![], colors:vec![] }; 125 | let canvas_gradient = CanvasGradient{ gradient:Arc::new(Mutex::new(bloom)) }; 126 | let this = RefCell::new(canvas_gradient); 127 | Ok(cx.boxed(this)) 128 | }else{ 129 | let msg = format!("Expected 6 arguments (x1, y1, r1, x2, y2, r2), received {}", cx.len() - 1); 130 | cx.throw_type_error(msg) 131 | } 132 | } 133 | 134 | pub fn conic(mut cx: FunctionContext) -> JsResult { 135 | if let [theta, x, y] = opt_float_args(&mut cx, 1..4).as_slice(){ 136 | let center = Point::new(*x, *y); 137 | let angle = to_degrees(*theta) - 90.0; 138 | let sweep = Gradient::Conic{ center, angle, stops:vec![], colors:vec![] }; 139 | let canvas_gradient = CanvasGradient{ gradient:Arc::new(Mutex::new(sweep)) }; 140 | let this = RefCell::new(canvas_gradient); 141 | Ok(cx.boxed(this)) 142 | }else{ 143 | let msg = format!("Expected 3 arguments (startAngle, x, y), received {}", cx.len() - 1); 144 | cx.throw_type_error(msg) 145 | } 146 | } 147 | 148 | pub fn addColorStop(mut cx: FunctionContext) -> JsResult { 149 | let this = cx.argument::(0)?; 150 | let offset = float_arg(&mut cx, 1, "offset")?; 151 | let color = color_arg(&mut cx, 2); 152 | 153 | let mut this = this.borrow_mut(); 154 | if let Some(color) = color { 155 | this.add_color_stop(offset, color); 156 | } 157 | 158 | Ok(cx.undefined()) 159 | } 160 | 161 | pub fn repr(mut cx: FunctionContext) -> JsResult { 162 | let this = cx.argument::(0)?; 163 | let this = this.borrow(); 164 | let gradient = Arc::clone(&this.gradient); 165 | let gradient = gradient.lock().unwrap(); 166 | 167 | let style = match &*gradient{ 168 | Gradient::Linear{..} => "Linear", 169 | Gradient::Radial{..} => "Radial", 170 | Gradient::Conic{..} => "Conic", 171 | }; 172 | 173 | Ok(cx.string(style)) 174 | } -------------------------------------------------------------------------------- /src/gui/app.rs: -------------------------------------------------------------------------------- 1 | use neon::prelude::*; 2 | use std::time::{Duration, Instant}; 3 | use serde_json::Value; 4 | use winit::{ 5 | application::ApplicationHandler, 6 | event::{ElementState, KeyEvent, StartCause, WindowEvent}, 7 | event_loop::{ActiveEventLoop, ControlFlow}, 8 | keyboard::{PhysicalKey, KeyCode}, 9 | window::WindowId 10 | }; 11 | 12 | use super::event::CanvasEvent; 13 | use super::window_mgr::WindowManager; 14 | use super::{add_event, new_proxy}; 15 | 16 | pub trait Roundtrip: FnMut(Value, &mut WindowManager) -> NeonResult<()>{} 17 | impl NeonResult<()>> Roundtrip for T {} 18 | 19 | pub struct App{ 20 | windows: WindowManager, 21 | cadence: Cadence, 22 | callback: F, 23 | } 24 | 25 | impl App{ 26 | pub fn with_callback(callback:F) -> Self{ 27 | let windows = WindowManager::default(); 28 | let cadence = Cadence::default(); 29 | Self{windows, cadence, callback} 30 | } 31 | 32 | fn initial_sync(&mut self){ 33 | let payload = self.windows.get_geometry(); 34 | let _ = (self.callback)(payload, &mut self.windows); 35 | } 36 | 37 | fn roundtrip(&mut self){ 38 | let payload = self.windows.get_ui_changes(); 39 | let _ = (self.callback)(payload, &mut self.windows); 40 | } 41 | } 42 | 43 | impl ApplicationHandler for App { 44 | fn resumed(&mut self, event_loop:&ActiveEventLoop){ 45 | 46 | } 47 | 48 | fn new_events(&mut self, event_loop:&ActiveEventLoop, cause:StartCause) { 49 | if cause == StartCause::Init{ 50 | // on initial pass, do a roundtrip to sync up the Window object's state attrs: 51 | // send just the initial window positions then read back all state 52 | self.initial_sync(); 53 | } 54 | } 55 | 56 | fn window_event( &mut self, event_loop:&ActiveEventLoop, window_id:WindowId, event:WindowEvent){ 57 | // route UI events to the relevant window 58 | self.windows.capture_ui_event(&window_id, &event); 59 | 60 | // handle window lifecycle events 61 | match event { 62 | WindowEvent::Destroyed | WindowEvent::CloseRequested => { 63 | self.windows.remove(&window_id); 64 | if self.windows.is_empty() { 65 | // quit after the last window is closed 66 | event_loop.exit(); 67 | } 68 | } 69 | WindowEvent::KeyboardInput { 70 | event: 71 | KeyEvent { 72 | physical_key: PhysicalKey::Code(KeyCode::Escape), 73 | state: ElementState::Pressed, 74 | repeat: false, 75 | .. 76 | }, 77 | .. 78 | } => { 79 | self.windows.set_fullscreen_state(&window_id, false); 80 | } 81 | 82 | #[cfg(target_os = "macos")] 83 | WindowEvent::Occluded(is_hidden) => { 84 | self.windows.send_event(&window_id, CanvasEvent::RedrawingSuspended(is_hidden)); 85 | } 86 | 87 | WindowEvent::RedrawRequested => { 88 | self.windows.send_event(&window_id, CanvasEvent::RedrawRequested); 89 | } 90 | 91 | WindowEvent::Resized(size) => { 92 | self.windows.send_event(&window_id, CanvasEvent::WindowResized(size)); 93 | } 94 | _ => {} 95 | } 96 | } 97 | 98 | fn user_event(&mut self, event_loop:&ActiveEventLoop, event:CanvasEvent) { 99 | match event{ 100 | CanvasEvent::Open(spec, page) => { 101 | self.windows.add(event_loop, new_proxy(), spec, page); 102 | } 103 | CanvasEvent::Close(token) => { 104 | self.windows.remove_by_token(&token); 105 | } 106 | CanvasEvent::Quit => { 107 | event_loop.exit(); 108 | } 109 | CanvasEvent::Render => { 110 | // relay UI-driven state changes to js and render the next frame in the (active) cadence 111 | self.roundtrip(); 112 | } 113 | CanvasEvent::Transform(window_id, matrix) => { 114 | self.windows.use_ui_transform(&window_id, &matrix); 115 | }, 116 | CanvasEvent::InFullscreen(window_id, is_fullscreen) => { 117 | self.windows.use_fullscreen_state(&window_id, is_fullscreen); 118 | } 119 | CanvasEvent::FrameRate(fps) => { 120 | self.cadence.set_frame_rate(fps) 121 | } 122 | _ => {} 123 | } 124 | } 125 | 126 | fn about_to_wait(&mut self, event_loop:&ActiveEventLoop) { 127 | // when no windows have frame/draw handlers, the (inactive) cadence will never trigger 128 | // a Render event, so only do a roundtrip if there are new UI events to be relayed 129 | if !self.cadence.active() && self.windows.has_ui_changes() { 130 | self.roundtrip(); 131 | } 132 | 133 | // delegate timing to the cadence if active, otherwise wait for ui events 134 | event_loop.set_control_flow( 135 | match self.cadence.active(){ 136 | true => self.cadence.on_next_frame(|| add_event(CanvasEvent::Render) ), 137 | false => ControlFlow::Wait 138 | } 139 | ); 140 | } 141 | 142 | } 143 | 144 | 145 | struct Cadence{ 146 | rate: u64, 147 | last: Instant, 148 | interval: Duration, 149 | begun: bool, 150 | } 151 | 152 | impl Default for Cadence { 153 | fn default() -> Self { 154 | Self{ 155 | rate: 0, 156 | last: Instant::now(), 157 | interval: Duration::new(0, 0), 158 | begun: false, 159 | } 160 | } 161 | } 162 | 163 | impl Cadence{ 164 | fn at_startup(&mut self) -> bool{ 165 | if self.begun{ false } 166 | else{ 167 | self.begun = true; 168 | true // only return true on first call 169 | } 170 | } 171 | 172 | fn set_frame_rate(&mut self, rate:u64){ 173 | if rate == self.rate{ return } 174 | let frame_time = 1_000_000_000/rate.max(1); 175 | self.interval = Duration::from_nanos(frame_time); 176 | self.rate = rate; 177 | } 178 | 179 | fn on_next_frame(&mut self, draw:F) -> ControlFlow{ 180 | match self.active() { 181 | true => { 182 | if self.last.elapsed() >= self.interval{ 183 | while self.last < Instant::now() - self.interval{ 184 | self.last += self.interval 185 | } 186 | draw(); 187 | } 188 | ControlFlow::WaitUntil(self.last + self.interval) 189 | }, 190 | false => ControlFlow::Wait, 191 | } 192 | } 193 | 194 | fn active(&self) -> bool{ 195 | self.rate > 0 196 | } 197 | } 198 | 199 | -------------------------------------------------------------------------------- /src/gui/event.rs: -------------------------------------------------------------------------------- 1 | use skia_safe::{Matrix, Color}; 2 | use serde::Serialize; 3 | use serde_json::json; 4 | use std::collections::HashSet; 5 | use winit::{ 6 | dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize}, 7 | event::{ElementState, KeyEvent, Ime, Modifiers, MouseButton, MouseScrollDelta, WindowEvent}, 8 | keyboard::{ModifiersState, KeyCode, KeyLocation, NamedKey, PhysicalKey::Code, Key::{Character, Named}}, 9 | platform::scancode::PhysicalKeyExtScancode, 10 | window::{CursorIcon, WindowId} 11 | }; 12 | 13 | use crate::context::page::Page; 14 | use super::window::{WindowSpec, Fit}; 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum CanvasEvent{ 18 | // app api 19 | Open(WindowSpec, Page), 20 | Close(String), 21 | FrameRate(u64), 22 | Quit, 23 | 24 | // app -> window 25 | Page(Page), 26 | 27 | // window -> app 28 | Transform(WindowId, Option), 29 | InFullscreen(WindowId, bool), 30 | 31 | // cadence triggers 32 | Render, 33 | 34 | // script -> window 35 | Title(String), 36 | Fullscreen(bool), 37 | Visible(bool), 38 | Resizable(bool), 39 | Cursor(Option), 40 | Background(Color), 41 | Fit(Fit), 42 | Position(LogicalPosition), 43 | Size(LogicalSize), 44 | 45 | // encapsulated WindowEvents 46 | WindowResized(PhysicalSize), 47 | RedrawRequested, 48 | RedrawingSuspended(bool), 49 | } 50 | 51 | #[derive(Debug, Serialize)] 52 | #[serde(rename_all = "lowercase")] 53 | pub enum UiEvent{ 54 | #[allow(non_snake_case)] 55 | Wheel{deltaX:f32, deltaY:f32}, 56 | Move{left:f32, top:f32}, 57 | Keyboard{event:String, key:String, code:KeyCode, location:u32, repeat:bool}, 58 | Composition{event:String, data:String}, 59 | Input(Option), 60 | Mouse(String), 61 | Focus(bool), 62 | Resize(LogicalSize), 63 | Fullscreen(bool), 64 | } 65 | 66 | 67 | #[derive(Debug)] 68 | pub struct Sieve{ 69 | dpr: f64, 70 | queue: Vec, 71 | key_modifiers: ModifiersState, 72 | mouse_point: PhysicalPosition::, 73 | mouse_button: Option, 74 | mouse_transform: Matrix, 75 | compose_begun: bool, 76 | compose_ongoing: bool, 77 | } 78 | 79 | impl Sieve{ 80 | pub fn new(dpr:f64) -> Self { 81 | Sieve{ 82 | dpr, 83 | queue: vec![], 84 | key_modifiers: Modifiers::default().state(), 85 | mouse_point: PhysicalPosition::default(), 86 | mouse_button: None, 87 | mouse_transform: Matrix::new_identity(), 88 | compose_begun: false, 89 | compose_ongoing: false, 90 | } 91 | } 92 | 93 | pub fn use_transform(&mut self, matrix:Matrix){ 94 | self.mouse_transform = matrix; 95 | } 96 | 97 | pub fn go_fullscreen(&mut self, is_full:bool){ 98 | self.queue.push(UiEvent::Fullscreen(is_full)); 99 | } 100 | 101 | pub fn capture(&mut self, event:&WindowEvent){ 102 | match event{ 103 | WindowEvent::Moved(physical_pt) => { 104 | let LogicalPosition{x, y} = physical_pt.to_logical(self.dpr); 105 | self.queue.push(UiEvent::Move{left:x, top:y}); 106 | } 107 | 108 | WindowEvent::Resized(physical_size) => { 109 | let logical_size = LogicalSize::from_physical(*physical_size, self.dpr); 110 | self.queue.push(UiEvent::Resize(logical_size)); 111 | } 112 | 113 | WindowEvent::Focused(in_focus) => { 114 | self.queue.push(UiEvent::Focus(*in_focus)); 115 | } 116 | 117 | WindowEvent::ModifiersChanged(modifiers) => { 118 | self.key_modifiers = modifiers.state(); 119 | } 120 | 121 | WindowEvent::CursorEntered{..} => { 122 | let mouse_event = "mouseenter".to_string(); 123 | self.queue.push(UiEvent::Mouse(mouse_event)); 124 | } 125 | 126 | WindowEvent::CursorLeft{..} => { 127 | let mouse_event = "mouseleave".to_string(); 128 | self.queue.push(UiEvent::Mouse(mouse_event)); 129 | } 130 | 131 | WindowEvent::CursorMoved{position, ..} => { 132 | if *position != self.mouse_point{ 133 | self.mouse_point = *position; 134 | self.queue.push(UiEvent::Mouse("mousemove".to_string())); 135 | } 136 | } 137 | 138 | WindowEvent::MouseWheel{delta, ..} => { 139 | let LogicalPosition{x, y} = match delta { 140 | MouseScrollDelta::PixelDelta(physical_pt) => { 141 | LogicalPosition::from_physical(*physical_pt, self.dpr) 142 | }, 143 | MouseScrollDelta::LineDelta(h, v) => { 144 | LogicalPosition{x:*h, y:*v} 145 | } 146 | }; 147 | self.queue.push(UiEvent::Wheel{deltaX:x, deltaY:y}); 148 | } 149 | 150 | WindowEvent::MouseInput{state, button, ..} => { 151 | let mouse_event = match state { 152 | ElementState::Pressed => "mousedown", 153 | ElementState::Released => "mouseup" 154 | }.to_string(); 155 | 156 | self.mouse_button = match button { 157 | MouseButton::Left => Some(0), 158 | MouseButton::Middle => Some(1), 159 | MouseButton::Right => Some(2), 160 | MouseButton::Back => Some(3), 161 | MouseButton::Forward => Some(4), 162 | MouseButton::Other(num) => Some(*num) 163 | }; 164 | self.queue.push(UiEvent::Mouse(mouse_event)); 165 | } 166 | 167 | WindowEvent::KeyboardInput { event: KeyEvent { 168 | physical_key:Code(key_code), logical_key, state, repeat, location, .. 169 | }, .. } => { 170 | 171 | // 172 | // `keyup`/`keydown` events 173 | // 174 | let event_type = match state { 175 | ElementState::Pressed => "keydown", 176 | ElementState::Released => "keyup", 177 | }.to_string(); 178 | 179 | let key_text = match logical_key{ 180 | Named(n) => serde_json::from_value(json!(n)).unwrap(), 181 | Character(c) => c.to_string(), 182 | _ => String::new() 183 | }; 184 | 185 | let key_location = match location{ 186 | KeyLocation::Standard => 0, 187 | KeyLocation::Left => 1, 188 | KeyLocation::Right => 2, 189 | KeyLocation::Numpad => 3, 190 | }; 191 | 192 | self.queue.push(UiEvent::Keyboard{ 193 | event: event_type, 194 | key: key_text.clone(), 195 | code: key_code.clone(), 196 | location: key_location, 197 | repeat: *repeat 198 | }); 199 | 200 | 201 | // 202 | // `input` events 203 | // 204 | if self.compose_ongoing{ 205 | // don't emit the un-composed keystroke if it's part of an IME composition 206 | self.compose_ongoing = match state{ 207 | ElementState::Released => false, 208 | _ => true, 209 | }; 210 | }else{ 211 | match state{ 212 | // ignore keyups, just report presses & repeats 213 | ElementState::Pressed => { 214 | // in addition to printable characters, report space & delete as input 215 | let key_char = match &logical_key{ 216 | Character(c) => Some(c.to_string()), 217 | Named(NamedKey::Space) => Some(" ".to_string()), 218 | Named(NamedKey::Backspace | NamedKey::Delete) => Some("".to_string()), 219 | _ => None 220 | }; 221 | 222 | if let Some(string) = key_char{ 223 | self.queue.push(UiEvent::Input(match !string.is_empty(){ 224 | true => Some(string), 225 | false => None, 226 | })); 227 | }; 228 | }, 229 | _ => {}, 230 | } 231 | } 232 | } 233 | 234 | WindowEvent::Ime( event, ..) => { 235 | match &event { 236 | Ime::Preedit(string, Some(range)) => { 237 | if !self.compose_begun{ 238 | self.queue.push(UiEvent::Composition{ 239 | event:"compositionstart".to_string(), data:"".to_string() 240 | }); 241 | self.compose_begun = true; // flag: don't emit another `start` until this commits 242 | } 243 | self.queue.push(UiEvent::Composition { 244 | event:"compositionupdate".to_string(), data:string.clone() 245 | }); 246 | self.compose_ongoing = true; // flag: don't emit `input` while composing 247 | }, 248 | Ime::Commit(string) => { 249 | self.queue.push(UiEvent::Composition { 250 | event:"compositionend".to_string(), data:string.clone() 251 | }); 252 | self.queue.push(UiEvent::Input(Some(string.clone()))); // emit the composed character 253 | self.compose_begun = false; 254 | }, 255 | _ => {} 256 | }; 257 | } 258 | 259 | _ => {} 260 | } 261 | } 262 | 263 | pub fn serialize(&mut self) -> Option{ 264 | if self.queue.is_empty(){ return None } 265 | 266 | let mut payload: Vec = vec![]; 267 | let mut mouse_events: HashSet = HashSet::new(); 268 | let mut modifiers:Option = None; 269 | let mut last_wheel:Option<&UiEvent> = None; 270 | 271 | for change in &self.queue { 272 | match change{ 273 | UiEvent::Mouse(event_type) => { 274 | modifiers = Some(self.key_modifiers); 275 | mouse_events.insert(event_type.clone()); 276 | } 277 | UiEvent::Wheel{..} => { 278 | modifiers = Some(self.key_modifiers); 279 | last_wheel = Some(&change); 280 | } 281 | UiEvent::Input(..) | UiEvent::Keyboard{..} => { 282 | modifiers = Some(self.key_modifiers); 283 | payload.push(json!(change)); 284 | } 285 | _ => payload.push(json!(change)) 286 | } 287 | } 288 | 289 | if let Some(modfiers) = modifiers { 290 | payload.insert(0, json!({"modifiers": modifiers})); 291 | } 292 | 293 | if !mouse_events.is_empty() { 294 | let viewport_point = LogicalPosition::::from_physical(self.mouse_point, self.dpr); 295 | let canvas_point = self.mouse_transform.map_point((viewport_point.x, viewport_point.y)); 296 | 297 | payload.push(json!({ 298 | "mouse": { 299 | "events": mouse_events, 300 | "button": self.mouse_button, 301 | "x": canvas_point.x, 302 | "y": canvas_point.y, 303 | "pageX": viewport_point.x, 304 | "pageY": viewport_point.y, 305 | } 306 | })); 307 | 308 | if mouse_events.contains("mouseup"){ 309 | self.mouse_button = None; 310 | } 311 | } 312 | 313 | if let Some(wheel) = last_wheel{ 314 | payload.push(json!(wheel)); 315 | } 316 | 317 | self.queue.clear(); 318 | Some(json!(payload)) 319 | } 320 | 321 | pub fn is_empty(&self) -> bool { 322 | self.queue.is_empty() 323 | } 324 | } 325 | 326 | -------------------------------------------------------------------------------- /src/gui/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_mut)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | #![allow(dead_code)] 5 | use neon::{prelude::*, result::Throw}; 6 | use std::iter::zip; 7 | use serde_json::Value; 8 | use std::cell::RefCell; 9 | use winit::{ 10 | event_loop::{ControlFlow, EventLoop, EventLoopProxy}, 11 | platform::run_on_demand::EventLoopExtRunOnDemand, 12 | }; 13 | 14 | use crate::utils::*; 15 | use crate::context::BoxedContext2D; 16 | 17 | pub mod app; 18 | use app::App; 19 | 20 | pub mod event; 21 | use event::CanvasEvent; 22 | 23 | pub mod window; 24 | use window::WindowSpec; 25 | 26 | pub mod window_mgr; 27 | use window_mgr::WindowManager; 28 | 29 | use crate::gpu::RenderingEngine; 30 | 31 | thread_local!( 32 | // the event loop can only be run from the main thread 33 | static EVENT_LOOP: RefCell> = RefCell::new(EventLoop::with_user_event().build().unwrap()); 34 | static PROXY: RefCell> = RefCell::new(EVENT_LOOP.with(|event_loop| 35 | event_loop.borrow().create_proxy() 36 | )); 37 | ); 38 | 39 | pub(crate) fn new_proxy() -> EventLoopProxy{ 40 | PROXY.with(|cell| cell.borrow().clone() ) 41 | } 42 | 43 | pub(crate) fn add_event(event: CanvasEvent){ 44 | PROXY.with(|cell| cell.borrow().send_event(event).ok() ); 45 | } 46 | 47 | fn validate_gpu(cx:&mut FunctionContext) -> Result<(), Throw>{ 48 | // bail out if we can't draw to the screen 49 | if let Some(reason) = RenderingEngine::default().lacks_gpu_support(){ 50 | cx.throw_error(reason)? 51 | } 52 | Ok(()) 53 | } 54 | 55 | pub fn launch(mut cx: FunctionContext) -> JsResult { 56 | let callback = cx.argument::(1)?; 57 | 58 | validate_gpu(&mut cx)?; 59 | 60 | // closure for using the callback to relay events to js and receive updates in return 61 | let roundtrip = |payload:Value, windows:&mut WindowManager| -> NeonResult<()>{ 62 | let cx = &mut cx; 63 | let null = cx.null(); 64 | 65 | // send payload to js for event dispatch and canvas drawing then read back new state & page data 66 | let events = cx.string(payload.to_string()).upcast::(); 67 | let response = callback.call(cx, null, vec![events])? 68 | .downcast::(cx).or_throw(cx)? 69 | .to_vec(cx)?; 70 | 71 | // unpack boxed contexts & window state objects 72 | let contexts:Vec> = response[1].downcast::(cx).or_throw(cx)?.to_vec(cx)?; 73 | let specs:Vec = serde_json::from_str( 74 | &response[0].downcast::(cx).or_throw(cx)?.value(cx) 75 | ).expect("Malformed response from window event handler"); 76 | 77 | // pass each window's new state & page data to the window manager 78 | zip(contexts, specs).for_each(|(boxed_ctx, spec)| { 79 | if let Ok(ctx) = boxed_ctx.downcast::(cx){ 80 | windows.update_window( 81 | spec.clone(), 82 | ctx.borrow().get_page() 83 | ) 84 | } 85 | }); 86 | Ok(()) 87 | }; 88 | 89 | EVENT_LOOP.with(|event_loop| { 90 | let mut app = App::with_callback(roundtrip); 91 | let mut event_loop = event_loop.borrow_mut(); 92 | event_loop.set_control_flow(ControlFlow::Wait); 93 | event_loop.run_app_on_demand(&mut app) 94 | }).ok(); 95 | 96 | Ok(cx.undefined()) 97 | } 98 | 99 | pub fn set_rate(mut cx: FunctionContext) -> JsResult { 100 | let fps = float_arg(&mut cx, 1, "framesPerSecond")? as u64; 101 | add_event(CanvasEvent::FrameRate(fps)); 102 | Ok(cx.number(fps as f64)) 103 | } 104 | 105 | pub fn open(mut cx: FunctionContext) -> JsResult { 106 | let win_config = string_arg(&mut cx, 0, "Window configuration")?; 107 | let context = cx.argument::(1)?; 108 | let spec = serde_json::from_str::(&win_config).expect("Invalid window state"); 109 | 110 | validate_gpu(&mut cx)?; 111 | 112 | add_event(CanvasEvent::Open(spec, context.borrow().get_page())); 113 | Ok(cx.undefined()) 114 | } 115 | 116 | pub fn close(mut cx: FunctionContext) -> JsResult { 117 | let token = string_arg(&mut cx, 0, "windowID")?; 118 | add_event(CanvasEvent::Close(token)); 119 | Ok(cx.undefined()) 120 | } 121 | 122 | pub fn quit(mut cx: FunctionContext) -> JsResult { 123 | add_event(CanvasEvent::Quit); 124 | Ok(cx.undefined()) 125 | } 126 | -------------------------------------------------------------------------------- /src/gui/window.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use skia_safe::{Matrix, Color, Paint}; 3 | use serde::{Serialize, Deserialize}; 4 | use winit::{ 5 | dpi::{LogicalSize, LogicalPosition, PhysicalSize}, 6 | event_loop::{ActiveEventLoop, EventLoopProxy}, 7 | window::{Window as WinitWindow, CursorIcon, Fullscreen}, 8 | }; 9 | #[cfg(target_os = "macos" )] 10 | use winit::platform::macos::WindowExtMacOS; 11 | 12 | use crate::utils::css_to_color; 13 | use crate::gpu::Renderer; 14 | use crate::context::page::Page; 15 | use super::event::CanvasEvent; 16 | 17 | #[derive(Deserialize, Serialize, Debug, Clone)] 18 | #[serde(rename_all = "camelCase")] 19 | pub struct WindowSpec { 20 | pub id: String, 21 | pub left: Option, 22 | pub top: Option, 23 | pub title: String, 24 | pub visible: bool, 25 | pub resizable: bool, 26 | pub fullscreen: bool, 27 | pub background: String, 28 | pub page: u32, 29 | pub width: f32, 30 | pub height: f32, 31 | #[serde(with = "Cursor")] 32 | pub cursor: CursorIcon, 33 | pub cursor_hidden: bool, 34 | pub fit: Fit, 35 | } 36 | 37 | #[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] 38 | #[serde(rename_all = "kebab-case")] 39 | pub enum Fit{ 40 | None, ContainX, ContainY, Contain, Cover, Fill, ScaleDown, Resize 41 | } 42 | 43 | #[non_exhaustive] 44 | #[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] 45 | #[serde(rename_all = "kebab-case", remote = "CursorIcon" )] 46 | pub enum Cursor { 47 | Alias, AllScroll, Cell, ColResize, ContextMenu, Copy, Crosshair, Default, EResize, 48 | EwResize, Grab, Grabbing, Help, Move, NeResize, NeswResize, NoDrop, NotAllowed, 49 | NResize, NsResize, NwResize, NwseResize, Pointer, Progress, RowResize, SeResize, 50 | SResize, SwResize, Text, VerticalText, Wait, WResize, ZoomIn, ZoomOut, 51 | } 52 | pub struct Window { 53 | pub handle: Arc, 54 | proxy: EventLoopProxy, 55 | renderer: Renderer, 56 | fit: Fit, 57 | background: Color, 58 | page: Page, 59 | suspended: bool, 60 | } 61 | 62 | impl Window { 63 | pub fn new(event_loop:&ActiveEventLoop, proxy:EventLoopProxy, spec: &mut WindowSpec, page: &Page) -> Self { 64 | let size:LogicalSize = LogicalSize::new(spec.width as i32, spec.height as i32); 65 | let background = match css_to_color(&spec.background){ 66 | Some(color) => color, 67 | None => { 68 | spec.background = "rgba(16,16,16,0.85)".to_string(); 69 | css_to_color(&spec.background).unwrap() 70 | } 71 | }; 72 | 73 | let window_attributes = WinitWindow::default_attributes() 74 | .with_fullscreen(if spec.fullscreen{ Some(Fullscreen::Borderless(None)) }else{ None }) 75 | .with_inner_size(size) 76 | .with_transparent(background.a() < 255) 77 | .with_title(spec.title.clone()) 78 | .with_visible(false) 79 | .with_resizable(spec.resizable); 80 | let handle = Arc::new(event_loop.create_window(window_attributes).unwrap()); 81 | 82 | if let (Some(left), Some(top)) = (spec.left, spec.top){ 83 | handle.set_outer_position(LogicalPosition::new(left, top)); 84 | } 85 | 86 | let renderer = Renderer::for_window(&event_loop, handle.clone()); 87 | 88 | Self{ handle, proxy, renderer, page:page.clone(), fit:spec.fit, suspended:false, background } 89 | } 90 | 91 | pub fn resize(&mut self, size: PhysicalSize){ 92 | if let Some(monitor) = self.handle.current_monitor(){ 93 | self.renderer.resize(size); 94 | self.reposition_ime(size); 95 | 96 | let id = self.handle.id(); 97 | self.proxy.send_event(CanvasEvent::Transform(id, self.fitting_matrix().invert() )).ok(); 98 | self.proxy.send_event(CanvasEvent::InFullscreen(id, monitor.size() == size )).ok(); 99 | } 100 | } 101 | 102 | pub fn reposition_ime(&mut self, size:PhysicalSize){ 103 | // place the input region in the bottom left corner so the UI doesn't cover the window 104 | let dpr = self.handle.scale_factor(); 105 | let window_height = size.to_logical::(dpr).height; 106 | self.handle.set_ime_allowed(true); 107 | self.handle.set_ime_cursor_area( 108 | LogicalPosition::new(15, window_height-20), LogicalSize::new(100, 15) 109 | ); 110 | } 111 | 112 | pub fn fitting_matrix(&self) -> Matrix { 113 | let dpr = self.handle.scale_factor(); 114 | let size = self.handle.inner_size().to_logical::(dpr); 115 | let dims = self.page.bounds.size(); 116 | let fit_x = size.width / dims.width; 117 | let fit_y = size.height / dims.height; 118 | 119 | let sf = match self.fit{ 120 | Fit::Cover => fit_x.max(fit_y), 121 | Fit::ScaleDown => fit_x.min(fit_y).min(1.0), 122 | Fit::Contain => fit_x.min(fit_y), 123 | Fit::ContainX => fit_x, 124 | Fit::ContainY => fit_y, 125 | _ => 1.0 126 | }; 127 | 128 | let (x_scale, y_scale) = match self.fit{ 129 | Fit::Fill => (fit_x, fit_y), 130 | _ => (sf, sf) 131 | }; 132 | 133 | let (x_shift, y_shift) = match self.fit{ 134 | Fit::Resize => (0.0, 0.0), 135 | _ => ( (size.width - dims.width * x_scale) / 2.0, 136 | (size.height - dims.height * y_scale) / 2.0 ) 137 | }; 138 | 139 | let mut matrix = Matrix::new_identity(); 140 | matrix.set_scale_translate( 141 | (x_scale, y_scale), 142 | (x_shift, y_shift) 143 | ); 144 | matrix 145 | } 146 | 147 | 148 | pub fn redraw(&mut self){ 149 | let paint = Paint::default(); 150 | let matrix = self.fitting_matrix(); 151 | let (clip, _) = matrix.map_rect(self.page.bounds); 152 | 153 | self.renderer.draw(&self.handle, |canvas, _size| { 154 | canvas.clear(self.background); 155 | canvas.clip_rect(clip, None, Some(true)); 156 | canvas.draw_picture(self.page.get_picture(None).unwrap(), Some(&matrix), Some(&paint)); 157 | }).unwrap(); 158 | } 159 | 160 | pub fn handle_event(&mut self, event:CanvasEvent){ 161 | match event { 162 | CanvasEvent::Page(page) => { 163 | self.page = page; 164 | self.handle.request_redraw(); 165 | } 166 | CanvasEvent::Visible(flag) => { 167 | self.handle.set_visible(flag); 168 | } 169 | CanvasEvent::Resizable(flag) => { 170 | self.handle.set_resizable(flag); 171 | } 172 | CanvasEvent::Title(title) => { 173 | self.handle.set_title(&title); 174 | } 175 | CanvasEvent::Cursor(icon) => { 176 | if let Some(icon) = icon{ 177 | self.handle.set_cursor(icon); 178 | } 179 | self.handle.set_cursor_visible(icon.is_some()); 180 | } 181 | CanvasEvent::Fit(mode) => { 182 | self.fit = mode; 183 | } 184 | CanvasEvent::Background(color) => { 185 | self.background = color; 186 | } 187 | CanvasEvent::Size(size) => { 188 | let size:PhysicalSize = size.to_physical(self.handle.scale_factor()); 189 | if let Some(to_size) = self.handle.request_inner_size(size){ 190 | self.resize(to_size); 191 | } 192 | } 193 | CanvasEvent::Position(loc) => { 194 | self.handle.set_outer_position(loc); 195 | } 196 | CanvasEvent::Fullscreen(to_fullscreen) => { 197 | match to_fullscreen{ 198 | true => self.handle.set_fullscreen( Some(Fullscreen::Borderless(None)) ), 199 | false => self.handle.set_fullscreen( None ) 200 | } 201 | } 202 | CanvasEvent::WindowResized(size) => { 203 | self.resize(size); 204 | } 205 | CanvasEvent::RedrawingSuspended(suspended) => { 206 | self.suspended = suspended; 207 | if !suspended{ 208 | self.redraw(); 209 | } 210 | } 211 | CanvasEvent::RedrawRequested => { 212 | if !self.suspended{ 213 | self.redraw() 214 | } 215 | } 216 | 217 | _ => {} 218 | } 219 | } 220 | } 221 | 222 | -------------------------------------------------------------------------------- /src/gui/window_mgr.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | use serde_json::json; 3 | use skia_safe::Matrix; 4 | use crossbeam::channel::{self, Sender}; 5 | use serde_json::{Map, Value}; 6 | use winit::{ 7 | dpi::{LogicalSize, LogicalPosition}, 8 | event::WindowEvent, 9 | event_loop::{ActiveEventLoop, EventLoopProxy}, 10 | window::WindowId, 11 | }; 12 | 13 | use crate::utils::css_to_color; 14 | use crate::context::page::Page; 15 | use super::event::{CanvasEvent, Sieve}; 16 | use super::window::{Window, WindowSpec}; 17 | 18 | struct WindowRef { tx: Sender, id: WindowId, spec: WindowSpec, sieve:Sieve } 19 | 20 | #[derive(Default)] 21 | pub struct WindowManager { 22 | windows: Vec, 23 | last: Option>, 24 | } 25 | 26 | impl WindowManager { 27 | 28 | pub fn add(&mut self, event_loop:&ActiveEventLoop, proxy:EventLoopProxy, mut spec: WindowSpec, page: Page) { 29 | let mut window = Window::new(event_loop, proxy, &mut spec, &page); 30 | let id = window.handle.id(); 31 | let (tx, rx) = channel::bounded(50); 32 | let mut sieve = Sieve::new(window.handle.scale_factor()); 33 | if let Some(fit) = window.fitting_matrix().invert(){ 34 | sieve.use_transform(fit); 35 | } 36 | 37 | // cascade the windows based on the position of the most recently opened 38 | let dpr = window.handle.scale_factor(); 39 | if let Ok(auto_loc) = window.handle.outer_position().map(|pt| pt.to_logical::(dpr)){ 40 | if let Ok(inset) = window.handle.inner_position().map(|pt| pt.to_logical::(dpr)){ 41 | let delta = inset.y - auto_loc.y; 42 | let reference = self.last.unwrap_or(auto_loc); 43 | let (left, top) = ( spec.left.unwrap_or(reference.x), spec.top.unwrap_or(reference.y) ); 44 | 45 | window.handle.set_outer_position(LogicalPosition::new(left, top)); 46 | window.handle.set_visible(true); 47 | 48 | spec.left = Some(left); 49 | spec.top = Some(top); 50 | self.last = Some(LogicalPosition::new(left + delta, top + delta)); 51 | } 52 | } 53 | 54 | // hold a reference to the window on the main thread… 55 | self.windows.push( WindowRef{ id, spec, tx, sieve } ); 56 | 57 | // …but let the window's event handler & renderer run on a background thread 58 | thread::spawn(move || { 59 | while let Ok(event) = rx.recv() { 60 | let mut queue = vec![event]; 61 | while !rx.is_empty(){ 62 | queue.push(rx.recv().unwrap()); 63 | } 64 | 65 | let mut needs_redraw = None; 66 | queue.drain(..).for_each(|event|{ 67 | match event { 68 | CanvasEvent::RedrawRequested => needs_redraw = Some(event), 69 | _ => window.handle_event(event) 70 | } 71 | }); 72 | 73 | // deduplicate and defer redraw requests until all other events were handled 74 | if let Some(event) = needs_redraw { 75 | window.handle_event(event) 76 | } 77 | } 78 | }); 79 | 80 | } 81 | 82 | pub fn remove(&mut self, window_id:&WindowId){ 83 | self.windows.retain(|win| win.id != *window_id); 84 | } 85 | 86 | pub fn remove_by_token(&mut self, token:&str){ 87 | self.windows.retain(|win| win.spec.id != token); 88 | } 89 | 90 | pub fn send_event(&self, window_id:&WindowId, event:CanvasEvent){ 91 | if let Some(tx) = self.windows.iter().find(|win| win.id == *window_id).map(|win| &win.tx){ 92 | tx.send(event).ok(); 93 | } 94 | } 95 | 96 | pub fn update_window(&mut self, mut spec:WindowSpec, page:Page){ 97 | let mut updates:Vec = vec![]; 98 | 99 | if let Some(mut win) = self.windows.iter_mut().find(|win| win.spec.id == spec.id){ 100 | if spec.width != win.spec.width || spec.height != win.spec.height { 101 | updates.push(CanvasEvent::Size(LogicalSize::new(spec.width as u32, spec.height as u32))); 102 | } 103 | 104 | if let (Some(left), Some(top)) = (spec.left, spec.top){ 105 | if spec.left != win.spec.left || spec.top != win.spec.top { 106 | updates.push(CanvasEvent::Position(LogicalPosition::new(left as i32, top as i32))); 107 | } 108 | } 109 | 110 | if spec.title != win.spec.title { 111 | updates.push(CanvasEvent::Title(spec.title.clone())); 112 | } 113 | 114 | if spec.visible != win.spec.visible { 115 | updates.push(CanvasEvent::Visible(spec.visible)); 116 | } 117 | 118 | if spec.fullscreen != win.spec.fullscreen { 119 | updates.push(CanvasEvent::Fullscreen(spec.fullscreen)); 120 | } 121 | 122 | if spec.resizable != win.spec.resizable { 123 | updates.push(CanvasEvent::Resizable(spec.resizable)); 124 | } 125 | 126 | if spec.cursor != win.spec.cursor || spec.cursor_hidden != win.spec.cursor_hidden { 127 | let icon = if spec.cursor_hidden{ None }else{ Some(spec.cursor) }; 128 | updates.push(CanvasEvent::Cursor(icon)); 129 | } 130 | 131 | if spec.fit != win.spec.fit { 132 | updates.push(CanvasEvent::Fit(spec.fit)); 133 | } 134 | 135 | if spec.background != win.spec.background { 136 | if let Some(color) = css_to_color(&spec.background) { 137 | updates.push(CanvasEvent::Background(color)); 138 | }else{ 139 | spec.background = win.spec.background.clone(); 140 | } 141 | } 142 | 143 | updates.push(CanvasEvent::Page(page)); 144 | 145 | updates.drain(..).for_each(|event| { 146 | win.tx.send(event).ok(); 147 | }); 148 | 149 | win.spec = spec; 150 | } 151 | } 152 | 153 | pub fn capture_ui_event(&mut self, window_id:&WindowId, event:&WindowEvent){ 154 | if let Some(win) = self.windows.iter_mut().find(|win| win.id == *window_id){ 155 | win.sieve.capture(event); 156 | } 157 | } 158 | 159 | pub fn use_ui_transform(&mut self, window_id:&WindowId, matrix:&Option){ 160 | if let Some(win) = self.windows.iter_mut().find(|win| win.id == *window_id){ 161 | if let Some(matrix) = matrix { 162 | win.sieve.use_transform(*matrix); 163 | } 164 | } 165 | } 166 | 167 | pub fn set_fullscreen_state(&mut self, window_id:&WindowId, is_fullscreen:bool){ 168 | if let Some(win) = self.windows.iter_mut().find(|win| win.id == *window_id){ 169 | // tell the window to change state 170 | win.tx.send(CanvasEvent::Fullscreen(is_fullscreen)).ok(); 171 | } 172 | // and make sure the change is reflected in local state 173 | self.use_fullscreen_state(window_id, is_fullscreen); 174 | } 175 | 176 | pub fn use_fullscreen_state(&mut self, window_id:&WindowId, is_fullscreen:bool){ 177 | if let Some(mut win) = self.windows.iter_mut().find(|win| win.id == *window_id){ 178 | if win.spec.fullscreen != is_fullscreen{ 179 | win.sieve.go_fullscreen(is_fullscreen); 180 | win.spec.fullscreen = is_fullscreen; 181 | } 182 | } 183 | } 184 | 185 | pub fn has_ui_changes(&self) -> bool { 186 | self.windows.iter().any(|win| !win.sieve.is_empty() ) 187 | } 188 | 189 | pub fn get_ui_changes(&mut self) -> Value { 190 | let mut ui = Map::new(); 191 | let mut state = Map::new(); 192 | self.windows.iter_mut().for_each(|win|{ 193 | if let Some(payload) = win.sieve.serialize(){ 194 | ui.insert(win.spec.id.clone(), payload); 195 | } 196 | state.insert(win.spec.id.clone(), json!(win.spec)); 197 | }); 198 | json!({ "ui": ui, "state": state }) 199 | } 200 | 201 | pub fn get_geometry(&mut self) -> Value { 202 | let mut positions = Map::new(); 203 | self.windows.iter_mut().for_each(|win|{ 204 | positions.insert(win.spec.id.clone(), json!({"left":win.spec.left, "top":win.spec.top})); 205 | }); 206 | json!({"geom":positions}) 207 | } 208 | 209 | pub fn len(&self) -> usize { 210 | self.windows.len() 211 | } 212 | 213 | pub fn is_empty(&self) -> bool { 214 | self.windows.len() == 0 215 | } 216 | } 217 | 218 | 219 | 220 | -------------------------------------------------------------------------------- /src/image.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_mut)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | #![allow(dead_code)] 5 | use std::cell::RefCell; 6 | use neon::{prelude::*, types::buffer::TypedArray}; 7 | use skia_safe::{ 8 | Image as SkImage, ImageInfo, ISize, ColorType, ColorSpace, AlphaType, Data, Size, 9 | FontMgr, Picture, PictureRecorder, Rect, image::images, 10 | svg::{self, Length, LengthUnit}, 11 | // wrapper::PointerWrapper // for SVG Dom access, temporary until next skia-safe update 12 | }; 13 | use crate::utils::*; 14 | use crate::context::Context2D; 15 | use crate::FONT_LIBRARY; 16 | 17 | pub type BoxedImage = JsBox>; 18 | impl Finalize for Image {} 19 | 20 | pub struct Image{ 21 | src:String, 22 | pub autosized:bool, 23 | pub content: Content, 24 | } 25 | 26 | impl Default for Image{ 27 | fn default() -> Self { 28 | Image{ content:Content::Loading, autosized:false, src:"".to_string() } 29 | } 30 | } 31 | 32 | pub enum Content{ 33 | Bitmap(SkImage), 34 | Vector(Picture), 35 | Loading, 36 | Broken, 37 | } 38 | 39 | impl Default for Content{ 40 | fn default() -> Self { 41 | Content::Loading 42 | } 43 | } 44 | 45 | impl Clone for Content{ 46 | fn clone(&self) -> Self { 47 | match self{ 48 | Content::Bitmap(img) => Content::Bitmap(img.clone()), 49 | Content::Vector(pict) => Content::Vector(pict.clone()), 50 | _ => Content::default() 51 | } 52 | } 53 | } 54 | 55 | impl Content{ 56 | pub fn from_context(ctx:&mut Context2D, use_vector:bool) -> Self{ 57 | match use_vector{ 58 | true => ctx.get_picture().map(|p| Content::Vector(p)), 59 | false => ctx.get_image().map(|i| Content::Bitmap(i)), 60 | }.unwrap_or_default() 61 | } 62 | 63 | pub fn from_image_data(image_data:ImageData) -> Self{ 64 | let info = image_data.image_info(); 65 | images::raster_from_data(&info, &image_data.buffer, info.min_row_bytes()) 66 | .map(|image| Content::Bitmap(image) ) 67 | .unwrap_or_default() 68 | } 69 | 70 | pub fn size(&self) -> Size { 71 | match &self { 72 | Content::Bitmap(img) => img.dimensions().into(), 73 | Content::Vector(pict) => pict.cull_rect().size().to_ceil().into(), // really cull_rect? 74 | _ => Size::new_empty() 75 | } 76 | } 77 | 78 | pub fn is_complete(&self) -> bool { 79 | match &self{ 80 | Content::Loading => false, 81 | _ => true 82 | } 83 | } 84 | 85 | pub fn is_drawable(&self) -> bool { 86 | match &self{ 87 | Content::Loading | Content::Broken => false, 88 | _ => true 89 | } 90 | } 91 | 92 | pub fn snap_rects_to_bounds(&self, mut src: Rect, mut dst: Rect) -> (Rect, Rect) { 93 | // Handle 'overdraw' of the src image where the crop coordinates are outside of its bounds 94 | // Snap the src rect to its actual bounds and shift/pad the dst rect to account for the 95 | // whitespace included in the crop. 96 | let scale_x = dst.width() / src.width(); 97 | let scale_y = dst.height() / src.height(); 98 | let size = self.size(); 99 | 100 | if src.left < 0.0 { 101 | dst.left += -src.left * scale_x; 102 | src.left = 0.0; 103 | } 104 | 105 | if src.top < 0.0 { 106 | dst.top += -src.top * scale_y; 107 | src.top = 0.0; 108 | } 109 | 110 | if src.right > size.width{ 111 | dst.right -= (src.right - size.width) * scale_x; 112 | src.right = size.width; 113 | } 114 | 115 | if src.bottom > size.height{ 116 | dst.bottom -= (src.bottom - size.height) * scale_y; 117 | src.bottom = size.height; 118 | } 119 | 120 | (src, dst) 121 | } 122 | } 123 | 124 | 125 | #[derive(Debug)] 126 | pub struct ImageData{ 127 | pub width: f32, 128 | pub height: f32, 129 | pub buffer: Data, 130 | color_type: ColorType, 131 | color_space: ColorSpace, 132 | } 133 | 134 | impl ImageData{ 135 | pub fn new(buffer:Data, width:f32, height:f32, color_type:String, color_space:String) -> Self{ 136 | let color_type = to_color_type(&color_type); 137 | let color_space = to_color_space(&color_space); 138 | Self{ buffer, width, height, color_type, color_space } 139 | } 140 | 141 | pub fn image_info(&self) -> ImageInfo{ 142 | ImageInfo::new( 143 | (self.width as _, self.height as _), 144 | self.color_type, 145 | AlphaType::Unpremul, 146 | self.color_space.clone() 147 | ) 148 | } 149 | } 150 | 151 | 152 | 153 | // 154 | // -- Javascript Methods -------------------------------------------------------------------------- 155 | // 156 | 157 | pub fn new(mut cx: FunctionContext) -> JsResult { 158 | let this = RefCell::new(Image::default()); 159 | Ok(cx.boxed(this)) 160 | } 161 | 162 | pub fn get_src(mut cx: FunctionContext) -> JsResult { 163 | let this = cx.argument::(0)?; 164 | let this = this.borrow(); 165 | 166 | Ok(cx.string(&this.src)) 167 | } 168 | 169 | pub fn set_src(mut cx: FunctionContext) -> JsResult { 170 | let this = cx.argument::(0)?; 171 | let mut this = this.borrow_mut(); 172 | 173 | let src = cx.argument::(1)?.value(&mut cx); 174 | this.src = src; 175 | Ok(cx.undefined()) 176 | } 177 | 178 | pub fn set_data(mut cx: FunctionContext) -> JsResult { 179 | let this = cx.argument::(0)?; 180 | let mut this = this.borrow_mut(); 181 | let buffer = cx.argument::(1)?; 182 | let data = Data::new_copy(buffer.as_slice(&cx)); 183 | 184 | // First try decoding the data as a bitmap, if invalid try parsing as SVG 185 | if let Some(image) = images::deferred_from_encoded_data(&data, None){ 186 | this.content = Content::Bitmap(image); 187 | }else if let Ok(mut dom) = svg::Dom::from_bytes(&data, FONT_LIBRARY.lock().unwrap().font_mgr()){ 188 | let mut root = dom.root(); 189 | 190 | let mut size = root.intrinsic_size(); 191 | if size.is_empty(){ 192 | // flag that image lacks an intrinsic size so it will be drawn to match the canvas size 193 | // if dimensions aren't provided in the drawImage() call 194 | this.autosized = true; 195 | 196 | // If width or height attributes aren't defined on the root `` element, they will be reported as "100%". 197 | // If only one is defined, use it for both dimensions, and if both are missing use the aspect ratio to scale the 198 | // width vs a fixed height of 150 (i.e., Chrome's behavior) 199 | let Length{ value:width, unit:w_unit } = root.width(); 200 | let Length{ value:height, unit:h_unit } = root.height(); 201 | size = match ((width, w_unit), (height, h_unit)){ 202 | // NB: only unitless numeric lengths are currently being handled; values in em, cm, in, etc. are ignored, 203 | // but perhaps they should be converted to px? 204 | ((100.0, LengthUnit::Percentage), (height, LengthUnit::Number)) => (*height, *height).into(), 205 | ((width, LengthUnit::Number), (100.0, LengthUnit::Percentage)) => (*width, *width).into(), 206 | _ => { 207 | let aspect = root.view_box().map(|vb| vb.width()/vb.height()).unwrap_or(1.0); 208 | (150.0 * aspect, 150.0).into() 209 | } 210 | }; 211 | }; 212 | 213 | // Save the SVG contents as a Picture (to be drawn later) 214 | let bounds = Rect::from_size(size); 215 | let mut compositor = PictureRecorder::new(); 216 | dom.set_container_size(bounds.size()); 217 | dom.render(compositor.begin_recording(bounds, None)); 218 | if let Some(picture) = compositor.finish_recording_as_picture(Some(&bounds)){ 219 | this.content = Content::Vector(picture); 220 | } 221 | }else{ 222 | this.content = Content::Broken 223 | } 224 | 225 | Ok(cx.boolean(this.content.is_drawable())) 226 | } 227 | 228 | pub fn get_width(mut cx: FunctionContext) -> JsResult { 229 | let this = cx.argument::(0)?; 230 | let this = this.borrow(); 231 | Ok(cx.number(this.content.size().width).upcast()) 232 | } 233 | 234 | pub fn get_height(mut cx: FunctionContext) -> JsResult { 235 | let this = cx.argument::(0)?; 236 | let this = this.borrow(); 237 | Ok(cx.number(this.content.size().height).upcast()) 238 | } 239 | 240 | pub fn get_complete(mut cx: FunctionContext) -> JsResult { 241 | let this = cx.argument::(0)?; 242 | let this = this.borrow(); 243 | Ok(cx.boolean(this.content.is_complete())) 244 | } 245 | 246 | pub fn pixels(mut cx: FunctionContext) -> JsResult { 247 | let this = cx.argument::(0)?; 248 | let mut this = this.borrow_mut(); 249 | let (color_type, color_space) = image_data_settings_arg(&mut cx, 1); 250 | 251 | let info = ImageInfo::new(this.content.size().to_floor(), color_type, AlphaType::Unpremul, color_space); 252 | let mut pixels = cx.buffer(info.bytes_per_pixel() * (info.width() * info.height()) as usize)?; 253 | 254 | match &this.content{ 255 | Content::Bitmap(image) => { 256 | match image.read_pixels(&info, pixels.as_mut_slice(&mut cx), info.min_row_bytes(), (0,0), skia_safe::image::CachingHint::Allow){ 257 | true => Ok(pixels.upcast()), 258 | false => Ok(cx.undefined().upcast()) 259 | } 260 | 261 | } 262 | _ => Ok(cx.undefined().upcast()) 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/pattern.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_mut)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | #![allow(non_snake_case)] 5 | #![allow(dead_code)] 6 | use std::cell::RefCell; 7 | use std::sync::{Arc, Mutex}; 8 | use neon::prelude::*; 9 | use skia_safe::{Shader, TileMode, TileMode::{Decal, Repeat}, SamplingOptions, Size, 10 | Image as SkImage, Picture, Matrix, FilterMode}; 11 | 12 | use crate::utils::*; 13 | use crate::image::{BoxedImage, Content}; 14 | use crate::context::BoxedContext2D; 15 | use crate::filter::ImageFilter; 16 | 17 | pub type BoxedCanvasPattern = JsBox>; 18 | impl Finalize for CanvasPattern {} 19 | 20 | 21 | pub struct Stamp{ 22 | content: Content, 23 | dims:Size, 24 | repeat:(TileMode, TileMode), 25 | matrix:Matrix 26 | } 27 | 28 | #[derive(Clone)] 29 | pub struct CanvasPattern{ 30 | pub stamp:Arc> 31 | } 32 | 33 | impl CanvasPattern{ 34 | pub fn shader(&self, image_filter: ImageFilter) -> Option{ 35 | let stamp = Arc::clone(&self.stamp); 36 | let stamp = stamp.lock().unwrap(); 37 | 38 | match &stamp.content{ 39 | Content::Bitmap(image) => 40 | image.to_shader(stamp.repeat, image_filter.sampling(), None).map(|shader| 41 | shader.with_local_matrix(&stamp.matrix) 42 | ), 43 | Content::Vector(pict) => { 44 | let shader = pict.to_shader(stamp.repeat, FilterMode::Linear, None, None); 45 | Some(shader.with_local_matrix(&stamp.matrix)) 46 | }, 47 | _ => None 48 | } 49 | } 50 | } 51 | 52 | // 53 | // -- Javascript Methods -------------------------------------------------------------------------- 54 | // 55 | 56 | pub fn from_image(mut cx: FunctionContext) -> JsResult { 57 | let src = cx.argument::(1)?; 58 | let canvas_width = float_arg(&mut cx, 2, "width")?; 59 | let canvas_height = float_arg(&mut cx, 3, "height")?; 60 | let repetition = if cx.len() > 4 && cx.argument::(4)?.is_a::(&mut cx){ 61 | "".to_string() // null is a valid synonym for "repeat" (as is "") 62 | }else{ 63 | string_arg(&mut cx, 4, "repetition")? 64 | }; 65 | 66 | if let Some(repeat) = to_repeat_mode(&repetition){ 67 | let src = src.borrow(); 68 | let dims:Size = src.content.size().into(); 69 | let mut matrix = Matrix::new_identity(); 70 | 71 | if src.autosized && !dims.is_empty() { 72 | // If this flag is set (for SVG images with no intrinsic size) then we need to scale the image to 73 | // the canvas' smallest dimension. This preserves compatibility with how Chromium browsers behave. 74 | let min_size = f32::min(canvas_width, canvas_height); 75 | let factor = (min_size / dims.width, min_size / dims.height); 76 | matrix.set_scale(factor, None); 77 | } 78 | 79 | let content = src.content.clone(); 80 | let stamp = Arc::new(Mutex::new(Stamp{ 81 | content, dims, repeat, matrix 82 | })); 83 | Ok(cx.boxed(RefCell::new(CanvasPattern{stamp}))) 84 | }else{ 85 | cx.throw_error("Unknown pattern repeat style") 86 | } 87 | } 88 | 89 | pub fn from_image_data(mut cx: FunctionContext) -> JsResult { 90 | let src = image_data_arg(&mut cx, 1)?; 91 | let repetition = if cx.len() > 2 && cx.argument::(2)?.is_a::(&mut cx){ 92 | "".to_string() // null is a valid synonym for "repeat" (as is "") 93 | }else{ 94 | string_arg(&mut cx, 2, "repetition")? 95 | }; 96 | 97 | if let Some(repeat) = to_repeat_mode(&repetition){ 98 | let content = Content::from_image_data(src); 99 | let dims:Size = content.size().into(); 100 | let mut matrix = Matrix::new_identity(); 101 | let stamp = Arc::new(Mutex::new(Stamp{ 102 | content, dims, repeat, matrix 103 | })); 104 | Ok(cx.boxed(RefCell::new(CanvasPattern{stamp}))) 105 | }else{ 106 | cx.throw_error("Unknown pattern repeat style") 107 | } 108 | } 109 | 110 | pub fn from_canvas(mut cx: FunctionContext) -> JsResult { 111 | let src = cx.argument::(1)?; 112 | let repetition = if cx.len() > 2 && cx.argument::(2)?.is_a::(&mut cx){ 113 | "".to_string() // null is a valid synonym for "repeat" (as is "") 114 | }else{ 115 | string_arg(&mut cx, 2, "repetition")? 116 | }; 117 | 118 | if let Some(repeat) = to_repeat_mode(&repetition){ 119 | let mut ctx = src.borrow_mut(); 120 | 121 | let content = ctx.get_picture() 122 | .map(|picture| Content::Vector(picture)) 123 | .unwrap_or_default(); 124 | let dims = ctx.bounds.size(); 125 | let stamp = Stamp{ 126 | content, 127 | dims, 128 | repeat, 129 | matrix:Matrix::new_identity() 130 | }; 131 | let stamp = Arc::new(Mutex::new(stamp)); 132 | Ok(cx.boxed(RefCell::new(CanvasPattern{stamp}))) 133 | }else{ 134 | cx.throw_error("Unknown pattern repeat style") 135 | } 136 | } 137 | 138 | pub fn setTransform(mut cx: FunctionContext) -> JsResult { 139 | let this = cx.argument::(0)?; 140 | let matrix = matrix_arg(&mut cx, 1)?; 141 | let mut this = this.borrow_mut(); 142 | let stamp = Arc::clone(&this.stamp); 143 | let mut stamp = stamp.lock().unwrap(); 144 | 145 | stamp.matrix = matrix; 146 | Ok(cx.undefined()) 147 | } 148 | 149 | pub fn repr(mut cx: FunctionContext) -> JsResult { 150 | let this = cx.argument::(0)?; 151 | let mut this = this.borrow_mut(); 152 | 153 | let stamp = Arc::clone(&this.stamp); 154 | let stamp = stamp.lock().unwrap(); 155 | let style = match stamp.content{ 156 | Content::Bitmap(..) => "Bitmap", 157 | _ => "Canvas" 158 | }; 159 | 160 | Ok(cx.string(format!("{} {}×{}", style, stamp.dims.width, stamp.dims.height))) 161 | } -------------------------------------------------------------------------------- /src/texture.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_variables)] 2 | #![allow(unused_mut)] 3 | #![allow(dead_code)] 4 | #![allow(unused_imports)] 5 | #![allow(non_snake_case)] 6 | use std::cell::RefCell; 7 | use std::sync::{Arc, Mutex}; 8 | use std::f32::consts::PI; 9 | use neon::prelude::*; 10 | use skia_safe::{Path, Rect, Color, Color4f, Point, TileMode, Matrix, Paint, PaintStyle}; 11 | use skia_safe::{PathEffect, line_2d_path_effect, path_2d_path_effect}; 12 | 13 | use crate::utils::*; 14 | use crate::path::BoxedPath2D; 15 | 16 | #[derive(Debug)] 17 | struct Texture{ 18 | path: Option, 19 | color: Color, 20 | line: f32, 21 | angle: f32, 22 | scale: (f32, f32), 23 | shift: (f32, f32), 24 | } 25 | 26 | pub type BoxedCanvasTexture = JsBox>; 27 | impl Finalize for CanvasTexture {} 28 | 29 | impl Default for Texture { 30 | fn default() -> Self { 31 | Texture{path:None, color:Color::BLACK, line:1.0, angle:0.0, scale:(1.0, 1.0), shift:(0.0, 0.0)} 32 | } 33 | } 34 | 35 | #[derive(Clone)] 36 | pub struct CanvasTexture{ 37 | texture:Arc> 38 | } 39 | 40 | impl CanvasTexture{ 41 | pub fn mix_into(&self, paint: &mut Paint, alpha:f32){ 42 | let tile = Arc::clone(&self.texture); 43 | let tile = tile.lock().unwrap(); 44 | 45 | let mut matrix = Matrix::new_identity(); 46 | matrix 47 | .pre_translate(tile.shift) 48 | .pre_rotate(180.0 * tile.angle / PI, None); 49 | 50 | match &tile.path { 51 | Some(path) => { 52 | let path = path.with_transform(&Matrix::rotate_rad(tile.angle)); 53 | matrix.pre_scale(tile.scale, None); 54 | paint.set_path_effect(path_2d_path_effect::new(&matrix, &path)); 55 | } 56 | None => { 57 | let scale = tile.scale.0.max(tile.scale.1); 58 | matrix.pre_scale((scale, scale), None); 59 | paint.set_path_effect(line_2d_path_effect::new(tile.line, &matrix)); 60 | } 61 | }; 62 | 63 | if tile.line > 0.0{ 64 | paint.set_stroke_width(tile.line); 65 | paint.set_style(PaintStyle::Stroke); 66 | } 67 | 68 | let mut color:Color4f = tile.color.into(); 69 | color.a *= alpha; 70 | paint.set_color(color.to_color()); 71 | } 72 | 73 | pub fn spacing(&self) -> (f32, f32) { 74 | let tile = Arc::clone(&self.texture); 75 | let tile = tile.lock().unwrap(); 76 | tile.scale 77 | } 78 | 79 | pub fn to_color(&self, alpha:f32) -> Color { 80 | let tile = Arc::clone(&self.texture); 81 | let tile = tile.lock().unwrap(); 82 | 83 | let mut color:Color4f = tile.color.into(); 84 | color.a *= alpha; 85 | color.to_color() 86 | } 87 | 88 | } 89 | 90 | // 91 | // -- Javascript Methods -------------------------------------------------------------------------- 92 | // 93 | 94 | pub fn new(mut cx: FunctionContext) -> JsResult { 95 | let path = opt_path2d_arg(&mut cx, 1); 96 | let color = color_arg(&mut cx, 2).unwrap_or(Color::BLACK); 97 | let line = float_arg(&mut cx, 3, "line")?; 98 | let nums = float_args(&mut cx, 4..9)?; 99 | 100 | let texture = match nums.as_slice(){ 101 | [angle, h, v, x, y] => { 102 | let angle = *angle; 103 | let scale = (*h, *v); 104 | let shift = (*x, *y); 105 | Texture{path, color, line, angle, scale, shift} 106 | }, 107 | _ => Texture::default() 108 | }; 109 | 110 | let canvas_texture = CanvasTexture{ texture:Arc::new(Mutex::new(texture)) }; 111 | let this = RefCell::new(canvas_texture); 112 | Ok(cx.boxed(this)) 113 | } 114 | 115 | pub fn repr(mut cx: FunctionContext) -> JsResult { 116 | let this = cx.argument::(0)?; 117 | let this = this.borrow(); 118 | 119 | let tile = Arc::clone(&this.texture); 120 | let tile = tile.lock().unwrap(); 121 | 122 | let style = if tile.path.is_some(){ "Path" }else{ "Lines" }; 123 | Ok(cx.string(style)) 124 | } -------------------------------------------------------------------------------- /test/assets/AmstelvarAlpha-VF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/AmstelvarAlpha-VF.ttf -------------------------------------------------------------------------------- /test/assets/Monoton-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/Monoton-Regular.woff -------------------------------------------------------------------------------- /test/assets/Monoton-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/Monoton-Regular.woff2 -------------------------------------------------------------------------------- /test/assets/blend-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/blend-bg.png -------------------------------------------------------------------------------- /test/assets/blend-fg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/blend-fg.png -------------------------------------------------------------------------------- /test/assets/checkers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/checkers.png -------------------------------------------------------------------------------- /test/assets/globe.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/globe.jpg -------------------------------------------------------------------------------- /test/assets/halved-1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/halved-1.jpeg -------------------------------------------------------------------------------- /test/assets/halved-2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/halved-2.jpeg -------------------------------------------------------------------------------- /test/assets/image/format.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.bmp -------------------------------------------------------------------------------- /test/assets/image/format.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.gif -------------------------------------------------------------------------------- /test/assets/image/format.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.ico -------------------------------------------------------------------------------- /test/assets/image/format.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.jpg -------------------------------------------------------------------------------- /test/assets/image/format.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.pdf -------------------------------------------------------------------------------- /test/assets/image/format.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.png -------------------------------------------------------------------------------- /test/assets/image/format.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.raw -------------------------------------------------------------------------------- /test/assets/image/format.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 9 | 10 | 12 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/assets/image/format.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/image/format.webp -------------------------------------------------------------------------------- /test/assets/pentagon-cmyk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/pentagon-cmyk.jpg -------------------------------------------------------------------------------- /test/assets/pentagon-grayscale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/pentagon-grayscale.jpg -------------------------------------------------------------------------------- /test/assets/pentagon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/pentagon.png -------------------------------------------------------------------------------- /test/assets/pentagon.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/pentagon.raw -------------------------------------------------------------------------------- /test/assets/quadrants.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/quadrants.png -------------------------------------------------------------------------------- /test/assets/readme-header-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/readme-header-dark@2x.png -------------------------------------------------------------------------------- /test/assets/readme-header@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/readme-header@2x.png -------------------------------------------------------------------------------- /test/assets/rose.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/rose.webp -------------------------------------------------------------------------------- /test/assets/social-card@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/social-card@2x.png -------------------------------------------------------------------------------- /test/assets/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/star.png -------------------------------------------------------------------------------- /test/assets/state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samizdatco/skia-canvas/a6bf52159b53beb6b48b67d9186af5e98be97eef/test/assets/state.png -------------------------------------------------------------------------------- /test/assets/tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 9 | 10 | 12 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/media.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const _ = require('lodash'), 4 | fs = require('fs'), 5 | path = require('path'), 6 | {Canvas, Image, ImageData, FontLibrary, loadImage, loadImageData} = require('../lib'), 7 | simple = require('simple-get') 8 | 9 | jest.mock('simple-get', () => { 10 | const fs = require('fs') 11 | return { 12 | concat:function(src, callback){ 13 | let path = src.replace(/^https?:\//, process.cwd()) 14 | try{ 15 | var [statusCode, data] = [200, fs.readFileSync(path)] 16 | }catch(e){ 17 | var [statusCode, err] = [404, 'HTTP_ERROR_404'] 18 | } 19 | 20 | setTimeout(() => callback(err, {statusCode}, data) ) 21 | } 22 | } 23 | }) 24 | 25 | describe("Image", () => { 26 | var PATH = 'test/assets/pentagon.png', 27 | URL = `https://${PATH}`, 28 | BUFFER = fs.readFileSync(PATH), 29 | DATA_URI = `data:image/png;base64,${BUFFER.toString('base64')}`, 30 | FRESH = {complete:false, width:0, height:0}, 31 | LOADED = {complete:true, width:125, height:125}, 32 | FORMAT = 'test/assets/image/format', 33 | PARSED = {complete:true, width:60, height:60}, 34 | SVG_PATH = `${FORMAT}.svg`, 35 | SVG_URL = `https://${SVG_PATH}`, 36 | SVG_BUFFER = fs.readFileSync(SVG_PATH), 37 | SVG_DATA_URI = `data:image/svg;base64,${SVG_BUFFER.toString('base64')}`, 38 | img 39 | 40 | beforeEach(() => img = new Image() ) 41 | 42 | describe("can initialize bitmaps from", () => { 43 | test("buffer", async () => { 44 | expect(img).toMatchObject(FRESH) 45 | img.src = BUFFER 46 | await img.decode() 47 | expect(img).toMatchObject(LOADED) 48 | }) 49 | 50 | test("data uri", () => { 51 | expect(img).toMatchObject(FRESH) 52 | img.src = DATA_URI 53 | expect(img).toMatchObject(LOADED) 54 | }) 55 | 56 | test("local file", async () => { 57 | expect(img).toMatchObject(FRESH) 58 | img.src = PATH 59 | await img.decode() 60 | expect(img).toMatchObject(LOADED) 61 | }) 62 | 63 | test("http url", done => { 64 | expect(img).toMatchObject(FRESH) 65 | img.onload = loaded => { 66 | expect(loaded).toBe(img) 67 | expect(img).toMatchObject(LOADED) 68 | done() 69 | } 70 | img.src = URL 71 | }) 72 | 73 | test("loadImage call", async () => { 74 | expect(img).toMatchObject(FRESH) 75 | 76 | img = await loadImage(URL) 77 | expect(img).toMatchObject(LOADED) 78 | 79 | img = await loadImage(BUFFER) 80 | expect(img).toMatchObject(LOADED) 81 | 82 | img = await loadImage(DATA_URI) 83 | expect(img).toMatchObject(LOADED) 84 | 85 | img = await loadImage(PATH) 86 | expect(img).toMatchObject(LOADED) 87 | 88 | img = await loadImage(SVG_PATH) 89 | expect(img).toMatchObject(PARSED) 90 | 91 | expect(async () => { await loadImage('http://nonesuch') }).rejects.toEqual("HTTP_ERROR_404") 92 | }) 93 | }) 94 | 95 | describe("can initialize SVGs from", () => { 96 | test("buffer", async () => { 97 | expect(img).toMatchObject(FRESH) 98 | img.src = SVG_BUFFER 99 | await img.decode() 100 | expect(img).toMatchObject(PARSED) 101 | }) 102 | 103 | test("data uri", async () => { 104 | expect(img).toMatchObject(FRESH) 105 | img.src = SVG_DATA_URI 106 | await img.decode() 107 | expect(img).toMatchObject(PARSED) 108 | }) 109 | 110 | test("local file", async () => { 111 | expect(img).toMatchObject(FRESH) 112 | img.src = SVG_PATH 113 | await img.decode() 114 | expect(img).toMatchObject(PARSED) 115 | }) 116 | 117 | test("http url", done => { 118 | expect(img).toMatchObject(FRESH) 119 | img.onload = loaded => { 120 | expect(loaded).toBe(img) 121 | expect(img).toMatchObject(PARSED) 122 | done() 123 | } 124 | img.src = SVG_URL 125 | }) 126 | }) 127 | 128 | describe("sends notifications through", () => { 129 | test(".complete flag", async () => { 130 | expect(img.complete).toEqual(false) 131 | 132 | img.src = PATH 133 | await img.decode() 134 | expect(img.complete).toEqual(true) 135 | }) 136 | 137 | test(".onload callback", done => { 138 | // ensure that the fetch process can be overwritten while in flight 139 | img.onload = loaded => { throw Error("should not be called") } 140 | img.src = URL 141 | 142 | img.onload = function(){ 143 | // confirm that `this` is set correctly 144 | expect(this).toBe(img) 145 | done() 146 | } 147 | img.src = 'http://test/assets/globe.jpg' 148 | }) 149 | 150 | test(".onerror callback", done => { 151 | img.onerror = err => { 152 | expect(err).toEqual("HTTP_ERROR_404") 153 | done() 154 | } 155 | img.src = 'http://nonesuch' 156 | }) 157 | 158 | test(".decode promise", async () => { 159 | expect(()=> img.decode() ).rejects.toEqual(new Error('Image source not set')) 160 | 161 | img.src = URL 162 | let decoded = await img.decode() 163 | expect(decoded).toBe(img) 164 | 165 | // can load new data into existing Image 166 | img.src = 'http://test/assets/image/format.png' 167 | decoded = await img.decode() 168 | expect(decoded).toBe(img) 169 | 170 | // autoresolves once loaded 171 | expect(img.decode()).resolves.toEqual(img) 172 | }) 173 | }) 174 | 175 | describe("can decode format", () => { 176 | const asBuffer = path => fs.readFileSync(path) 177 | 178 | const asDataURI = path => { 179 | let ext = path.split('.').at(-1), 180 | mime = `image/${ext.replace('jpg', 'jpeg')}`, 181 | content = fs.readFileSync(path).toString('base64') 182 | return `data:${mime};base64,${content}` 183 | } 184 | 185 | async function testFormat(ext){ 186 | let path = `${FORMAT}.${ext}` 187 | 188 | let img = new Image() 189 | img.src = path 190 | await img.decode() 191 | expect(img).toMatchObject(PARSED) 192 | 193 | img = new Image() 194 | img.src = asDataURI(path) 195 | await img.decode() 196 | expect(img).toMatchObject(PARSED) 197 | 198 | img = new Image() 199 | img.src = asBuffer(path) 200 | await img.decode() 201 | expect(img).toMatchObject(PARSED) 202 | } 203 | 204 | test("PNG", async () => await testFormat("png") ) 205 | test("JPEG", async () => await testFormat("jpg") ) 206 | test("GIF", async () => await testFormat("gif") ) 207 | test("BMP", async () => await testFormat("bmp") ) 208 | test("ICO", async () => await testFormat("ico") ) 209 | test("WEBP", async () => await testFormat("webp") ) 210 | test("SVG", async () => await testFormat("svg") ) 211 | }) 212 | }) 213 | 214 | describe("ImageData", () => { 215 | var FORMAT = 'test/assets/image/format.raw', 216 | RGBA = {width:60, height:60, colorType:'rgba'}, 217 | BGRA = {width:60, height:60, colorType:'bgra'} 218 | 219 | describe("can be initialized from", () => { 220 | test("buffer", () => { 221 | let buffer = fs.readFileSync(FORMAT) 222 | let imgData = new ImageData(buffer, 60, 60) 223 | expect(imgData).toMatchObject(RGBA) 224 | 225 | expect(() => new ImageData(buffer, 60, 59)) 226 | .toThrow("ImageData dimensions must match buffer length") 227 | }) 228 | 229 | test("loadImageData call", done => { 230 | loadImageData(FORMAT, 60, 60).then(imgData => { 231 | expect(imgData).toMatchObject(RGBA) 232 | done() 233 | }) 234 | }) 235 | 236 | test("canvas content", () => { 237 | let canvas = new Canvas(60, 60), 238 | ctx = canvas.getContext("2d") 239 | let rgbaData = ctx.getImageData(0, 0, 60, 60) 240 | expect(rgbaData).toMatchObject(RGBA) 241 | let bgraData = ctx.getImageData(0, 0, 60, 60, {colorType:'bgra'}) 242 | expect(bgraData).toMatchObject(BGRA) 243 | }) 244 | }) 245 | }) 246 | 247 | describe("FontLibrary", ()=>{ 248 | let canvas, ctx, 249 | WIDTH = 512, HEIGHT = 512, 250 | findFont = font => path.join(__dirname, 'assets', font), 251 | pixel = (x, y) => Array.from(ctx.getImageData(x, y, 1, 1).data); 252 | 253 | beforeEach(() => { 254 | canvas = new Canvas(WIDTH, HEIGHT) 255 | ctx = canvas.getContext("2d") 256 | }) 257 | 258 | test("can list families", ()=>{ 259 | let fams = FontLibrary.families, 260 | sorted = fams.slice().sort(), 261 | unique = _.uniq(sorted); 262 | 263 | expect(fams.indexOf("Arial")>=0 || fams.indexOf("DejaVu Sans")>=0).toBe(true) 264 | expect(fams).toEqual(sorted) 265 | expect(fams).toEqual(unique) 266 | }) 267 | 268 | test("can check for a family", ()=>{ 269 | expect(FontLibrary.has("Arial") || FontLibrary.has("DejaVu Sans")).toBe(true) 270 | expect(FontLibrary.has("_n_o_n_e_s_u_c_h_")).toBe(false) 271 | }) 272 | 273 | test("can describe a family", ()=>{ 274 | let fam = FontLibrary.has("Arial") ? "Arial" 275 | : FontLibrary.has("DejaVu Sans") ? "DejaVu Sans" 276 | : null; 277 | 278 | if (fam){ 279 | let info = FontLibrary.family(fam) 280 | expect(info).toBeTruthy() 281 | expect(info).toHaveProperty('family') 282 | expect(info).toHaveProperty('weights') 283 | expect(info && typeof info.weights[0]).toBe('number'); 284 | expect(info).toHaveProperty('widths') 285 | expect(info && typeof info.widths[0]).toBe('string'); 286 | expect(info).toHaveProperty('styles') 287 | expect(info && typeof info.styles[0]).toBe('string'); 288 | } 289 | }) 290 | 291 | test("can register fonts", ()=>{ 292 | let ttf = findFont("AmstelvarAlpha-VF.ttf"), 293 | name = "AmstelvarAlpha", 294 | alias = "PseudonymousBosch"; 295 | 296 | // with real name 297 | expect(() => FontLibrary.use(ttf)).not.toThrow() 298 | expect(FontLibrary.has(name)).toBe(true) 299 | expect(_.get(FontLibrary.family(name), "weights")).toContain(400) 300 | 301 | // with alias 302 | expect(() => FontLibrary.use(alias, ttf)).not.toThrow() 303 | expect(FontLibrary.has(alias)).toBe(true) 304 | expect(_.get(FontLibrary.family(alias), "weights")).toContain(400) 305 | 306 | // fonts disappear after reset 307 | FontLibrary.reset() 308 | expect(FontLibrary.has(name)).toBe(false) 309 | expect(FontLibrary.has(alias)).toBe(false) 310 | }) 311 | 312 | test("can render woff2 fonts", ()=>{ 313 | for (const ext of ['woff', 'woff2']){ 314 | let woff = findFont("Monoton-Regular." + ext), 315 | name = "Monoton" 316 | expect(() => FontLibrary.use(woff)).not.toThrow() 317 | expect(FontLibrary.has(name)).toBe(true) 318 | 319 | ctx.font = '256px Monoton' 320 | ctx.fillText('G', 128, 256) 321 | 322 | // look for one of the gaps between the inline strokes of the G 323 | let bmp = ctx.getImageData(300, 172, 1, 1) 324 | expect(Array.from(bmp.data)).toEqual([0,0,0,0]) 325 | } 326 | 327 | 328 | FontLibrary.reset() 329 | }) 330 | 331 | }) 332 | -------------------------------------------------------------------------------- /test/visual/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Usage: node[mon] test/visual/index.js [port #] [options] 3 | ex: node test/visual/index.js 8081 -g 1 4 | ex: node test/visual/index.js -p 8081 -w 300 5 | 6 | options: 7 | [-p[port]] Set listening port number; default: 4000 8 | -g[pu] (0|1) Enable/disable GPU; default: default for skia-canvas build 9 | -w[idth] Set canvas width; default: 200 10 | -h[eight] Set canvas height; default: 200 11 | -c[c] Set default canvas background color; default: white 12 | -b[c] Set the page background color; default: system preference (dark/light) 13 | 14 | The leading '-' is optional. 15 | All options besides 'port' can also be set via URL parameters using their full names (eg. '?width=250&gpu=1"). 16 | */ 17 | "use strict"; 18 | 19 | var path = require('path') 20 | var express = require('express') 21 | var {Canvas} = require('../../lib') 22 | var tests = require('./tests') 23 | 24 | 25 | // Default options 26 | const defaults = { 27 | width: 200, // canvas dimensions 28 | height: 200, 29 | cc: '#FFFFFF', // default canvas fill color 30 | bc: undefined, // page bg color 31 | gpu: undefined, // use gpu 32 | } 33 | 34 | var port = 4000 // server listening port 35 | 36 | // Runtime options 37 | const option = {} 38 | 39 | function renderTest (canvas, name, cb) { 40 | if (!tests[name]) { 41 | throw new Error('Unknown test: ' + name) 42 | } 43 | 44 | try{ 45 | var ctx = canvas.getContext('2d') 46 | var initialFillStyle = ctx.fillStyle 47 | ctx.fillStyle = option.cc 48 | ctx.fillRect(0, 0, canvas.width, canvas.height) 49 | ctx.fillStyle = initialFillStyle 50 | ctx.imageSmoothingEnabled = true 51 | if (tests[name].length === 2) { 52 | tests[name](ctx, cb) 53 | } else { 54 | tests[name](ctx) 55 | cb(null) 56 | } 57 | }catch(e){ 58 | console.error(e) 59 | cb(e) 60 | } 61 | } 62 | 63 | function setOptionsFromQuery(query) { 64 | if (query.width == null) { 65 | // full reset when no query string for initial request or 'reset' button 66 | Object.assign(option, defaults) 67 | return 68 | } 69 | option.width = parseInt(query.width) || defaults.width 70 | option.height = parseInt(query.height) || defaults.height 71 | option.gpu = query.gpu && query.gpu != 'null' ? !!parseInt(query.gpu) : defaults.gpu 72 | option.cc = query.cc ? decodeURIComponent(query.cc) : defaults.cc 73 | if (query.alpha != null && option.cc.length == 7 && option.cc[0] == '#') { 74 | // add alpha component into overall color (because browser's color input doesn't do alpha) 75 | const a = Math.max(Math.min(Math.round(255 * parseFloat(query.alpha)), 255), 0) 76 | if (Number.isFinite(a)) 77 | option.cc += a.toString(16).padStart(2, '0') 78 | } 79 | option.bc_default = query.bc_default != null 80 | if (query.bc) 81 | option.bc = decodeURIComponent(query.bc) 82 | // console.log(option) 83 | } 84 | 85 | var app = express() 86 | 87 | // must go before static routes 88 | app.get('/', function (req, res) { 89 | setOptionsFromQuery(req.query) 90 | res.cookie("renderOptions", JSON.stringify(option), { sameSite: 'Strict' }) 91 | res.sendFile(path.join(__dirname, 'index.html')) 92 | }) 93 | 94 | app.use(express.static(path.join(__dirname, '../assets'))) 95 | app.use(express.static(path.join(__dirname))) 96 | 97 | app.get('/render', async function (req, res, next) { 98 | var canvas = new Canvas(option.width, option.height) 99 | if (option.gpu != undefined) 100 | canvas.gpu = option.gpu 101 | 102 | renderTest(canvas, req.query.name, async function (err) { 103 | if (err) return next(err) 104 | 105 | let data = await canvas.png 106 | res.contentType('image/png'); 107 | res.send(data) 108 | }) 109 | }) 110 | 111 | app.get('/pdf', async function (req, res, next) { 112 | var canvas = new Canvas(option.width, option.height) 113 | 114 | renderTest(canvas, req.query.name, async function (err) { 115 | if (err) return next(err) 116 | 117 | let data = await canvas.pdf 118 | res.contentType('application/pdf'); 119 | res.send(data) 120 | }) 121 | }) 122 | 123 | app.get('/svg', async function (req, res, next) { 124 | var canvas = new Canvas(option.width, option.height) 125 | 126 | renderTest(canvas, req.query.name, async function (err) { 127 | if (err) return next(err) 128 | 129 | let data = await canvas.svg 130 | res.contentType('image/svg+xml'); 131 | res.send(data) 132 | }) 133 | }) 134 | 135 | // Handle CLI arguments; these set default options 136 | for (let i=2; i < process.argv.length; ++i) { 137 | const arg = process.argv[i]; 138 | if (typeof arg == 'number') port = arg; 139 | else if ((/^-?p/i).test(arg)) port = parseInt(process.argv[++i]) || port; 140 | else if ((/^-?g/i).test(arg)) defaults.gpu = !!parseInt(process.argv[++i]); 141 | else if ((/^-?w/i).test(arg)) defaults.width = parseInt(process.argv[++i]) || defaults.width; 142 | else if ((/^-?h/i).test(arg)) defaults.height = parseInt(process.argv[++i]) || defaults.height; 143 | else if ((/^-?c/i).test(arg)) defaults.cc = process.argv[++i]; 144 | else if ((/^-?b/i).test(arg)) { defaults.bc = process.argv[++i]; option.bc_default = false; } 145 | else console.log("Ignoring unknown argument:", arg) 146 | } 147 | 148 | app.listen(port, function () { 149 | console.log('=> http://localhost:%d/', port) 150 | // console.log(' with options:', defaults) 151 | }) 152 | --------------------------------------------------------------------------------