From da9a2140f08f279ade4e9177e8dfedd2ffaac992 Mon Sep 17 00:00:00 2001 From: skdhg <46562212+skdhg@users.noreply.github.com> Date: Sun, 17 Dec 2023 20:03:48 +0545 Subject: [PATCH] 0.0.3 --- .github/workflows/CI.yml | 108 ++++++------- .npmignore | 1 + Cargo.toml | 3 +- README.md | 25 ++- __test__/data/result-bw.svg | 5 + __test__/data/result-firefox.svg | 2 +- __test__/data/result-photo.svg | 34 ++++ __test__/data/result-poster.svg | 236 ++++++++++++++++++++++++++++ __test__/data/result-raw.svg | 6 + __test__/data/result.svg | 2 +- __test__/index.spec.mjs | 54 +++++-- example/index.mjs | 13 +- example/result.svg | 242 ++++++++++++++--------------- index.d.ts | 18 ++- index.js | 6 +- package.json | 2 +- src/config.rs | 175 +++++++++++++++++++++ src/converter.rs | 256 +++++++++++++++++++++++++++++++ src/lib.rs | 148 +++++++++++------- src/svg.rs | 78 ++++++++++ 20 files changed, 1147 insertions(+), 267 deletions(-) create mode 100644 __test__/data/result-bw.svg create mode 100644 __test__/data/result-photo.svg create mode 100644 __test__/data/result-poster.svg create mode 100644 __test__/data/result-raw.svg create mode 100644 src/config.rs create mode 100644 src/converter.rs create mode 100644 src/svg.rs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 81f6c06..ad317ec 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -162,59 +162,59 @@ jobs: name: bindings-${{ matrix.settings.target }} path: ${{ env.APP_NAME }}.*.node if-no-files-found: error - build-freebsd: - runs-on: macos-12 - name: Build FreeBSD - steps: - - uses: actions/checkout@v3 - with: - submodules: true - - name: Build - id: build - uses: vmactions/freebsd-vm@v0 - env: - DEBUG: napi:* - RUSTUP_HOME: /usr/local/rustup - CARGO_HOME: /usr/local/cargo - RUSTUP_IO_THREADS: 1 - with: - envs: DEBUG RUSTUP_HOME CARGO_HOME RUSTUP_IO_THREADS - usesh: true - mem: 3000 - prepare: | - pkg install -y -f curl node libnghttp2 llvm cmake # Install the llvm package to fix the libclang error - curl -qL https://www.npmjs.com/install.sh | sh - npm install --location=global --ignore-scripts yarn - curl https://sh.rustup.rs -sSf --output rustup.sh - sh rustup.sh -y --profile minimal --default-toolchain beta - rustup component add rustfmt - export PATH="/usr/local/cargo/bin:$PATH" - echo "~~~~ rustc --version ~~~~" - rustc --version - echo "~~~~ node -v ~~~~" - node -v - echo "~~~~ yarn --version ~~~~" - yarn --version - run: | - export PATH="/usr/local/cargo/bin:$PATH" - pwd - ls -lah - whoami - env - freebsd-version - yarn install - yarn build - strip -x *.node - yarn test - rm -rf node_modules - rm -rf target - rm -rf .yarn/cache - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: bindings-freebsd - path: ${{ env.APP_NAME }}.*.node - if-no-files-found: error + # build-freebsd: + # runs-on: macos-12 + # name: Build FreeBSD + # steps: + # - uses: actions/checkout@v3 + # with: + # submodules: true + # - name: Build + # id: build + # uses: vmactions/freebsd-vm@v0 + # env: + # DEBUG: napi:* + # RUSTUP_HOME: /usr/local/rustup + # CARGO_HOME: /usr/local/cargo + # RUSTUP_IO_THREADS: 1 + # with: + # envs: DEBUG RUSTUP_HOME CARGO_HOME RUSTUP_IO_THREADS + # usesh: true + # mem: 3000 + # prepare: | + # pkg install -y -f curl node libnghttp2 llvm cmake # Install the llvm package to fix the libclang error + # curl -qL https://www.npmjs.com/install.sh | sh + # npm install --location=global --ignore-scripts yarn + # curl https://sh.rustup.rs -sSf --output rustup.sh + # sh rustup.sh -y --profile minimal --default-toolchain beta + # rustup component add rustfmt + # export PATH="/usr/local/cargo/bin:$PATH" + # echo "~~~~ rustc --version ~~~~" + # rustc --version + # echo "~~~~ node -v ~~~~" + # node -v + # echo "~~~~ yarn --version ~~~~" + # yarn --version + # run: | + # export PATH="/usr/local/cargo/bin:$PATH" + # pwd + # ls -lah + # whoami + # env + # freebsd-version + # yarn install + # yarn build + # strip -x *.node + # yarn test + # rm -rf node_modules + # rm -rf target + # rm -rf .yarn/cache + # - name: Upload artifact + # uses: actions/upload-artifact@v3 + # with: + # name: bindings-freebsd + # path: ${{ env.APP_NAME }}.*.node + # if-no-files-found: error test-macOS-windows-binding: name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} needs: @@ -460,7 +460,7 @@ jobs: name: Publish runs-on: ubuntu-latest needs: - - build-freebsd + # - build-freebsd - test-macOS-windows-binding - test-linux-x64-gnu-binding - test-linux-x64-musl-binding diff --git a/.npmignore b/.npmignore index ec144db..af84903 100644 --- a/.npmignore +++ b/.npmignore @@ -11,3 +11,4 @@ yarn.lock .yarn __test__ renovate.json +example \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e402ab2..73d5187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ crate-type = ["cdylib"] napi = { version = "2.12.2", default-features = false, features = ["napi4"] } napi-derive = "2.12.2" visioncortex = "0.8.6" -vtracer = "0.6.3" +fastrand = "1.8" +image = "0.24.7" [build-dependencies] napi-build = "2.0.1" diff --git a/README.md b/README.md index 436b6b6..02ad344 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,12 @@ import { Hierarchial, PathSimplifyMode, } from '@neplex/vectorizer'; -import { Transformer } from '@napi-rs/image'; import { readFile, writeFile } from 'node:fs/promises'; const src = await readFile('./raster.png'); const pixels = await new Transformer(src).rawPixels(); const svg = await vectorize(pixels, { - width: IMAGE_WIDTH, - height: IMAGE_HEIGHT, colorMode: ColorMode.Color, colorPrecision: 6, filterSpeckle: 4, @@ -45,6 +42,24 @@ await writeFile('./vector.svg', svg); If you want to use synchronous API, you can use `vectorizeSync` instead. +## API + +### `vectorize(data: Buffer, config?: Config | Preset): Promise` + +Takes an image buffer and returns a promise that resolves to an SVG string. + +### `vectorizeSync(data: Buffer, config?: Config | Preset): string` + +Takes an image buffer and returns an SVG string synchronously. + +### `vectorizeRaw(data: Buffer, args: RawDataConfig, config?: Config | Preset): Promise` + +Takes a raw pixel data buffer and returns a promise that resolves to an SVG string. + +### `vectorizeRawSync(data: Buffer, args: RawDataConfig, config?: Config | Preset): string` + +Takes a raw pixel data buffer and returns an SVG string synchronously. + ## Demo Generated under the following configuration: @@ -60,9 +75,7 @@ Generated under the following configuration: mode: PathSimplifyMode.Spline, layerDifference: 6, lengthThreshold: 4, - maxIterations: 2, - width: 1052, - height: 774 + maxIterations: 2 } ``` diff --git a/__test__/data/result-bw.svg b/__test__/data/result-bw.svg new file mode 100644 index 0000000..1bc24a6 --- /dev/null +++ b/__test__/data/result-bw.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/__test__/data/result-firefox.svg b/__test__/data/result-firefox.svg index 79f1de4..aedb063 100644 --- a/__test__/data/result-firefox.svg +++ b/__test__/data/result-firefox.svg @@ -1,5 +1,5 @@ - + diff --git a/__test__/data/result-photo.svg b/__test__/data/result-photo.svg new file mode 100644 index 0000000..5c766ab --- /dev/null +++ b/__test__/data/result-photo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/__test__/data/result-poster.svg b/__test__/data/result-poster.svg new file mode 100644 index 0000000..922dd79 --- /dev/null +++ b/__test__/data/result-poster.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/__test__/data/result-raw.svg b/__test__/data/result-raw.svg new file mode 100644 index 0000000..cb82a0b --- /dev/null +++ b/__test__/data/result-raw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/__test__/data/result.svg b/__test__/data/result.svg index 04bcfda..cb82a0b 100644 --- a/__test__/data/result.svg +++ b/__test__/data/result.svg @@ -1,5 +1,5 @@ - + diff --git a/__test__/index.spec.mjs b/__test__/index.spec.mjs index eb70352..29663e1 100644 --- a/__test__/index.spec.mjs +++ b/__test__/index.spec.mjs @@ -1,8 +1,9 @@ import test from 'ava' import { writeFile, readFile } from 'node:fs/promises' -import { Transformer } from '@napi-rs/image' -import { ColorMode, vectorize, PathSimplifyMode, Hierarchical } from '../index.js' +import { ColorMode, vectorize, PathSimplifyMode, Hierarchical, Preset, vectorizeRaw } from '../index.js' +import { Transformer } from '@napi-rs/image'; +const src = await readFile('./__test__/data/firefox-logo.png'); const config = { colorMode: ColorMode.Color, colorPrecision: 6, @@ -23,27 +24,60 @@ const configFirefox = { filterSpeckle: 14, colorPrecision: 8, mode: PathSimplifyMode.Polygon, - width: 432, - height: 420, layerDifference: 0 }; test('should vectorize image (simple)', async (t) => { const src = await readFile('./__test__/data/sample.png'); - const pixels = new Transformer(src); - const result = await vectorize(await pixels.rawPixels(), configCircle); + const result = await vectorize(src, configCircle); await writeFile('./__test__/data/result.svg', result); t.pass(); }) -test('should vectorize image (hard)', async (t) => { - const src = await readFile('./__test__/data/firefox-logo.png'); - const pixels = new Transformer(src); - const result = await vectorize(await pixels.rawPixels(), configFirefox); +test('should vectorize raw pixels data', async (t) => { + const src = await readFile('./__test__/data/sample.png'); + const raw = await new Transformer(src).rawPixels(); + const result = await vectorizeRaw(raw, configCircle, { + height: 100, + width: 100, + }); + + await writeFile('./__test__/data/result-raw.svg', result); + + t.pass(); +}) + +test('should vectorize image', async (t) => { + + const result = await vectorize(src, configFirefox); await writeFile('./__test__/data/result-firefox.svg', result); + t.pass(); +}) + +test('should vectorize image with preset bw', async (t) => { + const result = await vectorize(src, Preset.Bw) + + await writeFile('./__test__/data/result-bw.svg', result); + + t.pass(); +}) + +test('should vectorize image with preset Photo', async (t) => { + const result = await vectorize(src, Preset.Photo) + + await writeFile('./__test__/data/result-photo.svg', result); + + t.pass(); +}) + +test('should vectorize image with preset Poster', async (t) => { + const result = await vectorize(src, Preset.Poster) + + await writeFile('./__test__/data/result-poster.svg', result); + t.pass(); }) \ No newline at end of file diff --git a/example/index.mjs b/example/index.mjs index 19a4dc7..95dda6d 100644 --- a/example/index.mjs +++ b/example/index.mjs @@ -1,10 +1,7 @@ -import { Transformer } from '@napi-rs/image' -import { vectorize, ColorMode, Hierarchical, PathSimplifyMode } from '../index.js' -import { readFile, writeFile } from 'node:fs/promises' +import { vectorize, ColorMode, Hierarchical, PathSimplifyMode } from '../index.js'; +import { readFile, writeFile } from 'node:fs/promises'; -const WIDTH = 1052, HEIGHT = 744; const src = await readFile('./example/anime-girl.png'); -const pixels = await new Transformer(src).rawPixels(); const config = { colorMode: ColorMode.Color, @@ -17,10 +14,8 @@ const config = { layerDifference: 6, lengthThreshold: 4, maxIterations: 2, - width: WIDTH, - height: HEIGHT }; -const result = await vectorize(pixels, config); +const result = await vectorize(src, config); -await writeFile('./example/result.svg', result); \ No newline at end of file +await writeFile('./example/result.svg', result); diff --git a/example/result.svg b/example/result.svg index bb40e64..483ae5c 100644 --- a/example/result.svg +++ b/example/result.svg @@ -1,5 +1,5 @@ - + @@ -2198,7 +2198,7 @@ - + @@ -2570,7 +2570,7 @@ - + @@ -2687,7 +2687,7 @@ - + @@ -2698,7 +2698,7 @@ - + @@ -2832,7 +2832,7 @@ - + @@ -2861,7 +2861,7 @@ - + @@ -2919,7 +2919,7 @@ - + @@ -2936,7 +2936,7 @@ - + @@ -3083,7 +3083,7 @@ - + @@ -3132,7 +3132,7 @@ - + @@ -3162,7 +3162,7 @@ - + @@ -3199,14 +3199,14 @@ - + - + @@ -3236,9 +3236,9 @@ - + - + @@ -3291,7 +3291,7 @@ - + @@ -3394,7 +3394,7 @@ - + @@ -3407,7 +3407,7 @@ - + @@ -3427,7 +3427,7 @@ - + @@ -3442,13 +3442,13 @@ - + - + @@ -3543,7 +3543,7 @@ - + @@ -3555,7 +3555,7 @@ - + @@ -3650,11 +3650,11 @@ - + - + @@ -3700,7 +3700,7 @@ - + @@ -3726,7 +3726,7 @@ - + @@ -3748,7 +3748,7 @@ - + @@ -3972,7 +3972,7 @@ - + @@ -4025,7 +4025,7 @@ - + @@ -4046,7 +4046,7 @@ - + @@ -4058,7 +4058,7 @@ - + @@ -4074,7 +4074,7 @@ - + @@ -4123,7 +4123,7 @@ - + @@ -4133,7 +4133,7 @@ - + @@ -4253,11 +4253,11 @@ - + - - + + @@ -4290,7 +4290,7 @@ - + @@ -4317,7 +4317,7 @@ - + @@ -4338,10 +4338,10 @@ - + - + @@ -4411,7 +4411,7 @@ - + @@ -4441,7 +4441,7 @@ - + @@ -4453,7 +4453,7 @@ - + @@ -4496,7 +4496,7 @@ - + @@ -4513,8 +4513,8 @@ - - + + @@ -4619,7 +4619,7 @@ - + @@ -4673,7 +4673,7 @@ - + @@ -4685,14 +4685,14 @@ - + - + @@ -4701,7 +4701,7 @@ - + @@ -4749,7 +4749,7 @@ - + @@ -4771,7 +4771,7 @@ - + @@ -4787,7 +4787,7 @@ - + @@ -4920,21 +4920,21 @@ - - + + - + - + @@ -5012,7 +5012,7 @@ - + @@ -5078,7 +5078,7 @@ - + @@ -5117,7 +5117,7 @@ - + @@ -5127,7 +5127,7 @@ - + @@ -5197,7 +5197,7 @@ - + @@ -5260,7 +5260,7 @@ - + @@ -5303,7 +5303,7 @@ - + @@ -5334,7 +5334,7 @@ - + @@ -5343,7 +5343,7 @@ - + @@ -5390,10 +5390,10 @@ - + - + @@ -5411,7 +5411,7 @@ - + @@ -5461,12 +5461,12 @@ - - + + - + @@ -5484,7 +5484,7 @@ - + @@ -5499,7 +5499,7 @@ - + @@ -5530,7 +5530,7 @@ - + @@ -5603,7 +5603,7 @@ - + @@ -5622,13 +5622,13 @@ - + - + - + @@ -5652,7 +5652,7 @@ - + @@ -5662,14 +5662,14 @@ - + - + - + @@ -5705,8 +5705,8 @@ - - + + @@ -5714,7 +5714,7 @@ - + @@ -5778,11 +5778,11 @@ - + - + @@ -5790,7 +5790,7 @@ - + @@ -5798,10 +5798,10 @@ - + - + @@ -5832,7 +5832,7 @@ - + @@ -5919,7 +5919,7 @@ - + @@ -5934,10 +5934,10 @@ - + - + @@ -5972,7 +5972,7 @@ - + @@ -6004,7 +6004,7 @@ - + @@ -6025,7 +6025,7 @@ - + @@ -6079,7 +6079,7 @@ - + @@ -6105,13 +6105,13 @@ - + - - + + @@ -6137,7 +6137,7 @@ - + @@ -6147,7 +6147,7 @@ - + @@ -6158,20 +6158,20 @@ - + - - + + - + @@ -6192,13 +6192,13 @@ - + - + @@ -6206,7 +6206,7 @@ - + @@ -6296,11 +6296,11 @@ - + - + @@ -6309,7 +6309,7 @@ - + @@ -6331,7 +6331,7 @@ - + @@ -6344,13 +6344,13 @@ - + - + diff --git a/index.d.ts b/index.d.ts index 0af8433..d442831 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,6 +3,11 @@ /* auto-generated by NAPI-RS */ +export const enum Preset { + Bw = 0, + Poster = 1, + Photo = 2 +} export const enum ColorMode { Color = 0, Binary = 1 @@ -16,11 +21,6 @@ export const enum PathSimplifyMode { Polygon = 1, Spline = 2 } -export const enum Preset { - Bw = 0, - Poster = 1, - Photo = 2 -} export interface Config { colorMode: ColorMode hierarchical: Hierarchical @@ -33,8 +33,12 @@ export interface Config { maxIterations: number spliceThreshold: number pathPrecision?: number +} +export interface RawDataConfig { width: number height: number } -export function vectorize(source: Buffer, config: Config): Promise -export function vectorizeSync(source: Buffer, config: Config): string +export function vectorize(source: Buffer, config?: Config | Preset | undefined | null): Promise +export function vectorizeRaw(source: Buffer, args: RawDataConfig, config?: Config | Preset | undefined | null): Promise +export function vectorizeSync(source: Buffer, config?: Config | Preset | undefined | null): string +export function vectorizeRawSync(source: Buffer, args: RawDataConfig, config?: Config | Preset | undefined | null): string diff --git a/index.js b/index.js index 4f00641..f00bbb6 100644 --- a/index.js +++ b/index.js @@ -281,11 +281,13 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { ColorMode, Hierarchical, PathSimplifyMode, Preset, vectorize, vectorizeSync } = nativeBinding +const { Preset, ColorMode, Hierarchical, PathSimplifyMode, vectorize, vectorizeRaw, vectorizeSync, vectorizeRawSync } = nativeBinding +module.exports.Preset = Preset module.exports.ColorMode = ColorMode module.exports.Hierarchical = Hierarchical module.exports.PathSimplifyMode = PathSimplifyMode -module.exports.Preset = Preset module.exports.vectorize = vectorize +module.exports.vectorizeRaw = vectorizeRaw module.exports.vectorizeSync = vectorizeSync +module.exports.vectorizeRawSync = vectorizeRawSync diff --git a/package.json b/package.json index 33a96a1..7edb7c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neplex/vectorizer", - "version": "0.0.2", + "version": "0.0.3", "description": "A simple Node.js library to convert raster images into svg", "main": "index.js", "types": "index.d.ts", diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1d8df4f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,175 @@ +// Based on https://github.com/visioncortex/vtracer/blob/74f2a04a17d8c246d80c439fb162780160a7c3e9/cmdapp/src/config.rs + +use std::str::FromStr; +use visioncortex::PathSimplifyMode; + +use napi_derive::*; + +#[napi] +pub enum Preset { + Bw, + Poster, + Photo, +} + +#[napi] +pub enum ColorMode { + Color, + Binary, +} + +#[napi] +pub enum Hierarchical { + Stacked, + Cutout, +} + +/// Converter config +pub struct Config { + pub color_mode: ColorMode, + pub hierarchical: Hierarchical, + pub filter_speckle: usize, + pub color_precision: i32, + pub layer_difference: i32, + pub mode: PathSimplifyMode, + pub corner_threshold: i32, + pub length_threshold: f64, + pub max_iterations: usize, + pub splice_threshold: i32, + pub path_precision: Option, +} + +pub(crate) struct ConverterConfig { + pub color_mode: ColorMode, + pub hierarchical: Hierarchical, + pub filter_speckle_area: usize, + pub color_precision_loss: i32, + pub layer_difference: i32, + pub mode: PathSimplifyMode, + pub corner_threshold: f64, + pub length_threshold: f64, + pub max_iterations: usize, + pub splice_threshold: f64, + pub path_precision: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + mode: PathSimplifyMode::Spline, + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + corner_threshold: 60, + length_threshold: 4.0, + splice_threshold: 45, + max_iterations: 10, + path_precision: Some(2), + } + } +} + +impl FromStr for ColorMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "color" => Ok(Self::Color), + "binary" => Ok(Self::Binary), + _ => Err(format!("unknown ColorMode {}", s)), + } + } +} + +impl FromStr for Hierarchical { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "stacked" => Ok(Self::Stacked), + "cutout" => Ok(Self::Cutout), + _ => Err(format!("unknown Hierarchical {}", s)), + } + } +} + +impl FromStr for Preset { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "bw" => Ok(Self::Bw), + "poster" => Ok(Self::Poster), + "photo" => Ok(Self::Photo), + _ => Err(format!("unknown Preset {}", s)), + } + } +} + +impl Config { + pub fn from_preset(preset: Preset) -> Self { + match preset { + Preset::Bw => Self { + color_mode: ColorMode::Binary, + hierarchical: Hierarchical::Stacked, + filter_speckle: 4, + color_precision: 6, + layer_difference: 16, + mode: PathSimplifyMode::Spline, + corner_threshold: 60, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(2), + }, + Preset::Poster => Self { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + filter_speckle: 4, + color_precision: 8, + layer_difference: 16, + mode: PathSimplifyMode::Spline, + corner_threshold: 60, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(2), + }, + Preset::Photo => Self { + color_mode: ColorMode::Color, + hierarchical: Hierarchical::Stacked, + filter_speckle: 10, + color_precision: 8, + layer_difference: 48, + mode: PathSimplifyMode::Spline, + corner_threshold: 180, + length_threshold: 4.0, + max_iterations: 10, + splice_threshold: 45, + path_precision: Some(2), + }, + } + } + + pub(crate) fn into_converter_config(self) -> ConverterConfig { + ConverterConfig { + color_mode: self.color_mode, + hierarchical: self.hierarchical, + filter_speckle_area: self.filter_speckle * self.filter_speckle, + color_precision_loss: 8 - self.color_precision, + layer_difference: self.layer_difference, + mode: self.mode, + corner_threshold: deg2rad(self.corner_threshold), + length_threshold: self.length_threshold, + max_iterations: self.max_iterations, + splice_threshold: deg2rad(self.splice_threshold), + path_precision: self.path_precision, + } + } +} + +fn deg2rad(deg: i32) -> f64 { + deg as f64 / 180.0 * std::f64::consts::PI +} diff --git a/src/converter.rs b/src/converter.rs new file mode 100644 index 0000000..1d223d6 --- /dev/null +++ b/src/converter.rs @@ -0,0 +1,256 @@ +// Based on https://github.com/visioncortex/vtracer/blob/74f2a04a17d8c246d80c439fb162780160a7c3e9/cmdapp/src/converter.rs + +use crate::RawDataConfig; + +use super::config::{ColorMode, Config, ConverterConfig, Hierarchical}; +use super::svg::SvgFile; +use fastrand::Rng; +use image; +use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX}; +use visioncortex::{ + approximate_circle_with_spline, Color, ColorImage, ColorName, CompoundPath, PathSimplifyMode, +}; + +const NUM_UNUSED_COLOR_ITERATIONS: usize = 6; +/// The fraction of pixels in the top/bottom rows of the image that need to be transparent before +/// the entire image will be keyed. +const KEYING_THRESHOLD: f32 = 0.2; + +const SMALL_CIRCLE: i32 = 12; + +/// Convert an in-memory image into an in-memory SVG +pub fn convert(img: ColorImage, config: Config) -> Result { + let config = config.into_converter_config(); + match config.color_mode { + ColorMode::Color => color_image_to_svg(img, config), + ColorMode::Binary => binary_image_to_svg(img, config), + } +} + +/// Convert an image file into svg file +pub fn convert_image_to_svg( + input: &[u8], + config: Config, + raw: Option, +) -> Result { + let img = read_image(input, raw)?; + let svg = convert(img, config)?; + let str = svg.to_string().map_err(|e| e.to_string())?; + Ok(str) +} + +fn color_exists_in_image(img: &ColorImage, color: Color) -> bool { + for y in 0..img.height { + for x in 0..img.width { + let pixel_color = img.get_pixel(x, y); + if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b { + return true; + } + } + } + false +} + +fn find_unused_color_in_image(img: &ColorImage) -> Result { + let special_colors = IntoIterator::into_iter([ + Color::new(255, 0, 0), + Color::new(0, 255, 0), + Color::new(0, 0, 255), + Color::new(255, 255, 0), + Color::new(0, 255, 255), + Color::new(255, 0, 255), + ]); + let rng = Rng::new(); + let random_colors = + (0..NUM_UNUSED_COLOR_ITERATIONS).map(|_| Color::new(rng.u8(..), rng.u8(..), rng.u8(..))); + for color in special_colors.chain(random_colors) { + if !color_exists_in_image(img, color) { + return Ok(color); + } + } + Err(String::from( + "unable to find unused color in image to use as key", + )) +} + +fn should_key_image(img: &ColorImage) -> bool { + if img.width == 0 || img.height == 0 { + return false; + } + + // Check for transparency at several scanlines + let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize; + let mut num_transparent_boundary_pixels = 0; + let y_positions = [ + 0, + img.height / 4, + img.height / 2, + 3 * img.height / 4, + img.height - 1, + ]; + for y in y_positions { + for x in 0..img.width { + if img.get_pixel(x, y).a == 0 { + num_transparent_boundary_pixels += 1; + } + if num_transparent_boundary_pixels >= threshold { + return true; + } + } + } + + false +} + +fn color_image_to_svg(mut img: ColorImage, config: ConverterConfig) -> Result { + let width = img.width; + let height = img.height; + + let key_color = if should_key_image(&img) { + let key_color = find_unused_color_in_image(&img)?; + for y in 0..height { + for x in 0..width { + if img.get_pixel(x, y).a == 0 { + img.set_pixel(x, y, &key_color); + } + } + } + key_color + } else { + // The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied. + Color::default() + }; + + let runner = Runner::new( + RunnerConfig { + diagonal: config.layer_difference == 0, + hierarchical: HIERARCHICAL_MAX, + batch_size: 25600, + good_min_area: config.filter_speckle_area, + good_max_area: (width * height), + is_same_color_a: config.color_precision_loss, + is_same_color_b: 1, + deepen_diff: config.layer_difference, + hollow_neighbours: 1, + key_color, + keying_action: if matches!(config.hierarchical, Hierarchical::Cutout) { + KeyingAction::Keep + } else { + KeyingAction::Discard + }, + }, + img, + ); + + let mut clusters = runner.run(); + + match config.hierarchical { + Hierarchical::Stacked => {} + Hierarchical::Cutout => { + let view = clusters.view(); + let image = view.to_color_image(); + let runner = Runner::new( + RunnerConfig { + diagonal: false, + hierarchical: 64, + batch_size: 25600, + good_min_area: 0, + good_max_area: (image.width * image.height) as usize, + is_same_color_a: 0, + is_same_color_b: 1, + deepen_diff: 0, + hollow_neighbours: 0, + key_color, + keying_action: KeyingAction::Discard, + }, + image, + ); + clusters = runner.run(); + } + } + + let view = clusters.view(); + + let mut svg = SvgFile::new(width, height, config.path_precision); + for &cluster_index in view.clusters_output.iter().rev() { + let cluster = view.get_cluster(cluster_index); + let paths = if matches!(config.mode, PathSimplifyMode::Spline) + && cluster.rect.width() < SMALL_CIRCLE + && cluster.rect.height() < SMALL_CIRCLE + && cluster.to_shape(&view).is_circle() + { + let mut paths = CompoundPath::new(); + paths.add_spline(approximate_circle_with_spline( + cluster.rect.left_top(), + cluster.rect.width(), + )); + paths + } else { + cluster.to_compound_path( + &view, + false, + config.mode, + config.corner_threshold, + config.length_threshold, + config.max_iterations, + config.splice_threshold, + ) + }; + svg.add_path(paths, cluster.residue_color()); + } + + Ok(svg) +} + +fn binary_image_to_svg(img: ColorImage, config: ConverterConfig) -> Result { + let img = img.to_binary_image(|x| x.r < 128); + let width = img.width; + let height = img.height; + + let clusters = img.to_clusters(false); + + let mut svg = SvgFile::new(width, height, config.path_precision); + for i in 0..clusters.len() { + let cluster = clusters.get_cluster(i); + if cluster.size() >= config.filter_speckle_area { + let paths = cluster.to_compound_path( + config.mode, + config.corner_threshold, + config.length_threshold, + config.max_iterations, + config.splice_threshold, + ); + svg.add_path(paths, Color::color(&ColorName::Black)); + } + } + + Ok(svg) +} + +fn read_image(input: &[u8], raw: Option) -> Result { + match raw { + Some(raw) => { + let img = ColorImage { + pixels: input.to_vec(), + width: raw.width as usize, + height: raw.height as usize, + }; + Ok(img) + } + None => { + let img = image::load_from_memory(input); + let img = match img { + Ok(file) => file.to_rgba8(), + Err(_) => return Err(String::from("unable to read this image")), + }; + + let (width, height) = (img.width() as usize, img.height() as usize); + let img = ColorImage { + pixels: img.as_raw().to_vec(), + width, + height, + }; + Ok(img) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1866213..67f69fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,62 +1,55 @@ #![deny(clippy::all)] +use config::{ColorMode, Config, Hierarchical, Preset}; +use converter::convert_image_to_svg; use napi::{ bindgen_prelude::{AsyncTask, Buffer}, - Result, Task, + Either, Result, Task, }; -use visioncortex::PathSimplifyMode as VcPathSimplifyMode; -use vtracer; +use std::panic; +use visioncortex::PathSimplifyMode; #[macro_use] extern crate napi_derive; -#[napi] -pub enum ColorMode { - Color, - Binary, -} - -#[napi] -pub enum Hierarchical { - Stacked, - Cutout, -} +pub mod config; +pub mod converter; +pub mod svg; -#[napi] -pub enum PathSimplifyMode { +#[napi(js_name = "PathSimplifyMode")] +pub enum JsPathSimplifyMode { None, Polygon, Spline, } -#[napi] -pub enum Preset { - Bw, - Poster, - Photo, -} - #[derive(Clone)] -#[napi(object)] -pub struct Config { +#[napi(object, js_name = "Config")] +pub struct JsConfig { pub color_mode: ColorMode, pub hierarchical: Hierarchical, pub filter_speckle: i32, pub color_precision: i32, pub layer_difference: i32, - pub mode: PathSimplifyMode, + pub mode: JsPathSimplifyMode, pub corner_threshold: i32, pub length_threshold: f64, pub max_iterations: i32, pub splice_threshold: i32, pub path_precision: Option, +} + +#[derive(Clone)] +#[napi(object)] +pub struct RawDataConfig { pub width: i32, pub height: i32, } pub struct VectorizeTask { data: Buffer, - config: Config, + config: Option>, + args: Option, } #[napi] @@ -65,7 +58,7 @@ impl Task for VectorizeTask { type JsValue = String; fn compute(&mut self) -> Result { - let res = vectorize_inner(self.data.as_ref(), self.config.clone()); + let res = vectorize_inner(self.data.as_ref(), self.config.clone(), self.args.clone()); res } @@ -75,40 +68,94 @@ impl Task for VectorizeTask { } #[napi(catch_unwind)] -pub fn vectorize(source: Buffer, config: Config) -> AsyncTask { +pub fn vectorize( + source: Buffer, + config: Option>, +) -> AsyncTask { + AsyncTask::new(VectorizeTask { + data: source, + config, + args: None, + }) +} + +#[napi(catch_unwind)] +pub fn vectorize_raw( + source: Buffer, + args: RawDataConfig, + config: Option>, +) -> AsyncTask { AsyncTask::new(VectorizeTask { data: source, config, + args: Some(args), }) } #[napi(catch_unwind)] -pub fn vectorize_sync(source: Buffer, config: Config) -> Result { - vectorize_inner(source.as_ref(), config) +pub fn vectorize_sync(source: Buffer, config: Option>) -> Result { + vectorize_inner(source.as_ref(), config, None) } -fn vectorize_inner(source: &[u8], config: Config) -> Result { - let mut img = vtracer::ColorImage::new_w_h(config.width as usize, config.height as usize); - img.pixels = source.to_vec(); +#[napi(catch_unwind)] +pub fn vectorize_raw_sync( + source: Buffer, + args: RawDataConfig, + config: Option>, +) -> Result { + vectorize_inner(source.as_ref(), config, Some(args)) +} - let result = vtracer::convert( - img, - vtracer::Config { +fn create_config_with_preset(preset: Preset) -> Config { + Config::from_preset(preset) +} + +fn vectorize_inner( + source: &[u8], + config: Option>, + raw_args: Option, +) -> Result { + panic::set_hook(Box::new(|_info| {})); + + let result = + panic::catch_unwind(|| convert_image_to_svg(source, resolve_config(config), raw_args)); + + let result = match result { + Ok(res) => res, + Err(_) => Err(napi::Error::new( + napi::Status::GenericFailure, + "Unknown error occurred", + ))?, + }; + + let svg = result.map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("Error: {:?}", e).as_str(), + ) + })?; + + Ok(svg) +} + +fn resolve_config(config: Option>) -> Config { + match config { + Some(Either::A(config)) => Config { color_mode: match config.color_mode { - ColorMode::Color => vtracer::ColorMode::Color, - ColorMode::Binary => vtracer::ColorMode::Binary, + ColorMode::Color => ColorMode::Color, + ColorMode::Binary => ColorMode::Binary, }, hierarchical: match config.hierarchical { - Hierarchical::Stacked => vtracer::Hierarchical::Stacked, - Hierarchical::Cutout => vtracer::Hierarchical::Cutout, + Hierarchical::Stacked => Hierarchical::Stacked, + Hierarchical::Cutout => Hierarchical::Cutout, }, filter_speckle: config.filter_speckle as usize, color_precision: config.color_precision, layer_difference: config.layer_difference, mode: match config.mode { - PathSimplifyMode::None => VcPathSimplifyMode::None, - PathSimplifyMode::Polygon => VcPathSimplifyMode::Polygon, - PathSimplifyMode::Spline => VcPathSimplifyMode::Spline, + JsPathSimplifyMode::None => PathSimplifyMode::None, + JsPathSimplifyMode::Polygon => PathSimplifyMode::Polygon, + JsPathSimplifyMode::Spline => PathSimplifyMode::Spline, }, corner_threshold: config.corner_threshold, length_threshold: config.length_threshold, @@ -116,14 +163,7 @@ fn vectorize_inner(source: &[u8], config: Config) -> Result { splice_threshold: config.splice_threshold, path_precision: config.path_precision, }, - ); - - let svg = result.map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("Error: {:?}", e).as_str(), - ) - })?; - - Ok(svg.to_string()) + Some(Either::B(preset)) => create_config_with_preset(preset), + None => Config::default(), + } } diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..c460b15 --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,78 @@ +// Based on https://github.com/visioncortex/vtracer/blob/74f2a04a17d8c246d80c439fb162780160a7c3e9/cmdapp/src/svg.rs + +use std::fmt; +use visioncortex::{Color, CompoundPath, PointF64}; + +pub struct SvgFile { + pub paths: Vec, + pub width: usize, + pub height: usize, + pub path_precision: Option, +} + +pub struct SvgPath { + pub path: CompoundPath, + pub color: Color, +} + +impl SvgFile { + pub fn new(width: usize, height: usize, path_precision: Option) -> Self { + SvgFile { + paths: vec![], + width, + height, + path_precision, + } + } + + pub fn add_path(&mut self, path: CompoundPath, color: Color) { + self.paths.push(SvgPath { path, color }) + } + + pub fn to_string(&self) -> Result { + Ok(format!("{}", self)) + } +} + +impl fmt::Display for SvgFile { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, r#""#)?; + writeln!( + f, + r#""#, + )?; + writeln!( + f, + r#""#, + self.width, self.height + )?; + + for path in &self.paths { + path.fmt_with_precision(f, self.path_precision)?; + } + + writeln!(f, "") + } +} + +impl fmt::Display for SvgPath { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.fmt_with_precision(f, None) + } +} + +impl SvgPath { + fn fmt_with_precision(&self, f: &mut fmt::Formatter, precision: Option) -> fmt::Result { + let (string, offset) = self + .path + .to_svg_string(true, PointF64::default(), precision); + writeln!( + f, + "", + string, + self.color.to_hex_string(), + offset.x, + offset.y + ) + } +}