diff --git a/Cargo.toml b/Cargo.toml index 4a14862..d76211a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ exclude = [ "webapp", # pyo3 extension-module cdylib; built with maturin, not the core workspace. "crates/vtracer-py", + # wasm-bindgen cdylib; built with wasm-pack as the Node package's core. + "nodejs", ] resolver = "2" diff --git a/README.md b/README.md index c4bfb3d..7fe8f99 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,22 @@ vtracer.Config.poster().convert_file("photo.jpg", "poster.svg") See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API. +### Node.js Library + +[`vtracer`](https://www.npmjs.com/package/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**. + +```sh +npm install vtracer +``` + +```js +const vtracer = require('vtracer'); + +await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' }); +const svg = vtracer.convertBuffer(buffer, { preset: 'poster' }); +const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' }); +``` + ## Citations VTracer has since been cited by a few academic papers in computer graphics / vision research. Please kindly let us know if you have cited our work: diff --git a/nodejs/.gitignore b/nodejs/.gitignore new file mode 100644 index 0000000..ed60c86 --- /dev/null +++ b/nodejs/.gitignore @@ -0,0 +1,4 @@ +/pkg +/target +/node_modules +Cargo.lock diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml new file mode 100644 index 0000000..efe506e --- /dev/null +++ b/nodejs/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vtracer-wasm" +description = "WebAssembly core for the vtracer Node.js package." +version = "1.0.0-alpha.1" +authors = ["Chris Tsang "] +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/visioncortex/vtracer/" + +# Not the core workspace: this is a wasm-bindgen cdylib built with wasm-pack as +# the Node package's native core. The Node layer does file I/O; image decoding +# happens here in wasm, so the package has no native dependency. + +[lib] +crate-type = ["cdylib"] + +[dependencies] +vtracer = { version = "1.0.0-alpha.1", path = "../crates/vtracer" } +wasm-bindgen = "0.2" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" +# Pure-Rust decoders that compile to wasm32-unknown-unknown. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp"] } + +[profile.release] +opt-level = "s" +lto = true diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 0000000..fdd4793 --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,53 @@ +# vtracer (Node.js) + +Raster → vector (SVG) for Node, a WebAssembly build of the +[`vtracer`](https://github.com/visioncortex/vtracer) framework. Image decoding +and vectorization both happen in wasm, so there is **no native dependency** — +just `npm install`. + +## Install + +```sh +npm install vtracer +``` + +## Usage + +```js +const vtracer = require('vtracer'); + +// file in, file out +await vtracer.convertFile('in.png', 'out.svg'); +await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical: 'cutout' }); + +// buffers +const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' }); + +// raw RGBA8 pixels +const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' }); +``` + +## API + +- `convertBuffer(buffer, options?) => string` — encoded image (PNG/JPEG/GIF/BMP) → SVG. +- `convertPixels(rgba, width, height, options?) => string` — raw RGBA8 → SVG. +- `convertFile(input, output, options?) => Promise` — read, trace, write. +- `convertFileSync(input, output, options?) => void`. + +### `Options` (all optional, camelCase) + +`preset` (`"bw" | "poster" | "photo"`, applied first), `colorMode` +(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free +mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`, +`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`, +`maxIterations`, `spliceThreshold`, `pathPrecision`, `palette` (list of +`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`). + +## Build from source + +Requires the Rust toolchain and [`wasm-pack`](https://rustwasm.github.io/wasm-pack/): + +```sh +npm run build # wasm-pack build --target nodejs --out-dir pkg +npm test +``` diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts new file mode 100644 index 0000000..c782b56 --- /dev/null +++ b/nodejs/index.d.ts @@ -0,0 +1,34 @@ +/** Conversion options. Any field may be omitted; omitted fields use the framework default. */ +export interface Options { + /** Applied before other fields: "bw" | "poster" | "photo". */ + preset?: 'bw' | 'poster' | 'photo'; + colorMode?: 'color' | 'bw'; + hierarchical?: 'stacked' | 'cutout'; + mode?: 'pixel' | 'polygon' | 'spline'; + filterSpeckle?: number; + colorPrecision?: number; + layerDifference?: number; + cornerThreshold?: number; + lengthThreshold?: number; + maxIterations?: number; + spliceThreshold?: number; + pathPrecision?: number; + /** Fixed palette: `#rrggbb` strings. */ + palette?: string[]; + /** Auto-quantize target color count. */ + maxColors?: number; + /** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */ + optimize?: number; +} + +/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */ +export function convertBuffer(buffer: Uint8Array, options?: Options): string; + +/** Vectorize a raw RGBA8 buffer (`width * height * 4` bytes) to an SVG string. */ +export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string; + +/** Read an image file, vectorize it, and write the SVG to disk. */ +export function convertFile(inputPath: string, outputPath: string, options?: Options): Promise; + +/** Synchronous {@link convertFile}. */ +export function convertFileSync(inputPath: string, outputPath: string, options?: Options): void; diff --git a/nodejs/index.js b/nodejs/index.js new file mode 100644 index 0000000..8058bef --- /dev/null +++ b/nodejs/index.js @@ -0,0 +1,49 @@ +'use strict'; + +// Node package: image decoding + vectorization happen in wasm (no native +// dependency); this layer only adds file I/O and a camelCase API. + +const fs = require('fs'); +const fsp = require('fs/promises'); +const wasm = require('./pkg/vtracer_wasm.js'); + +/** + * Vectorize an encoded image (PNG/JPEG/GIF/BMP) Buffer/Uint8Array to an SVG string. + * @param {Uint8Array} buffer + * @param {object} [options] + * @returns {string} + */ +function convertBuffer(buffer, options = {}) { + return wasm.vectorize_bytes(buffer, options); +} + +/** + * Vectorize a raw RGBA8 buffer (width*height*4 bytes) to an SVG string. + * @param {Uint8Array} rgba + * @param {number} width + * @param {number} height + * @param {object} [options] + * @returns {string} + */ +function convertPixels(rgba, width, height, options = {}) { + return wasm.vectorize_rgba(rgba, width, height, options); +} + +/** + * Read an image file, vectorize it, and write the SVG to disk. + * @returns {Promise} + */ +async function convertFile(inputPath, outputPath, options = {}) { + const data = await fsp.readFile(inputPath); + const svg = wasm.vectorize_bytes(data, options); + await fsp.writeFile(outputPath, svg); +} + +/** Synchronous {@link convertFile}. */ +function convertFileSync(inputPath, outputPath, options = {}) { + const data = fs.readFileSync(inputPath); + const svg = wasm.vectorize_bytes(data, options); + fs.writeFileSync(outputPath, svg); +} + +module.exports = { convertBuffer, convertPixels, convertFile, convertFileSync }; diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..e1a5c7d --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,31 @@ +{ + "name": "vtracer", + "version": "1.0.0-alpha.1", + "description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "pkg/vtracer_wasm.js", + "pkg/vtracer_wasm_bg.wasm", + "pkg/vtracer_wasm.d.ts", + "pkg/vtracer_wasm_bg.wasm.d.ts" + ], + "scripts": { + "build": "wasm-pack build --target nodejs --out-dir pkg", + "test": "node test.js", + "prepublishOnly": "npm run build" + }, + "keywords": ["svg", "vectorization", "raster", "wasm", "computer-graphics"], + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/visioncortex/vtracer.git", + "directory": "nodejs" + }, + "homepage": "http://www.visioncortex.org/vtracer", + "engines": { + "node": ">=16" + } +} diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs new file mode 100644 index 0000000..4b063e2 --- /dev/null +++ b/nodejs/src/lib.rs @@ -0,0 +1,160 @@ +//! WebAssembly core for the vtracer Node package. +//! +//! Exposes vectorization over encoded image bytes or a raw RGBA buffer. Image +//! decoding happens here (in wasm), so the JS layer only needs `fs` — no +//! native dependency. Options are a plain JS object matching [`Options`]. + +use std::io::Cursor; + +use serde::Deserialize; +use vtracer::{Color, ColorImage, Config}; +use wasm_bindgen::prelude::*; + +/// Conversion options; a subset may be provided from JS (camelCase). Anything +/// omitted uses the framework default. +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct Options { + color_mode: Option, + hierarchical: Option, + mode: Option, + filter_speckle: Option, + color_precision: Option, + layer_difference: Option, + corner_threshold: Option, + length_threshold: Option, + max_iterations: Option, + splice_threshold: Option, + path_precision: Option, + palette: Option>, + max_colors: Option, + optimize: Option, + /// One of "bw" | "poster" | "photo"; applied before the other fields. + preset: Option, +} + +fn err(msg: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&msg.to_string()) +} + +fn parse_hex(token: &str) -> Result { + let hex = token.strip_prefix('#').unwrap_or(token); + if hex.len() != 6 { + return Err(err(format!("`{token}` is not a #rrggbb color"))); + } + let b = |r: std::ops::Range| { + u8::from_str_radix(&hex[r], 16).map_err(|_| err(format!("`{token}` is not a #rrggbb color"))) + }; + Ok(Color::new(b(0..2)?, b(2..4)?, b(4..6)?)) +} + +fn config_from(options: JsValue) -> Result { + let opts: Options = if options.is_undefined() || options.is_null() { + Options::default() + } else { + serde_wasm_bindgen::from_value(options).map_err(err)? + }; + + let mut config = match opts.preset.as_deref() { + Some("bw") => Config::from_preset(vtracer::Preset::Bw), + Some("poster") => Config::from_preset(vtracer::Preset::Poster), + Some("photo") => Config::from_preset(vtracer::Preset::Photo), + Some(other) => return Err(err(format!("unknown preset `{other}`"))), + None => Config::default(), + }; + + if let Some(v) = opts.color_mode { + config.color_mode = v.parse().map_err(err)?; + } + if let Some(v) = opts.hierarchical { + config.hierarchical = v.parse().map_err(err)?; + } + if let Some(v) = opts.mode { + config.mode = v.parse().map_err(err)?; + } + if let Some(v) = opts.filter_speckle { + config.filter_speckle = v; + } + if let Some(v) = opts.color_precision { + config.color_precision = v; + } + if let Some(v) = opts.layer_difference { + config.layer_difference = v; + } + if let Some(v) = opts.corner_threshold { + config.corner_threshold = v; + } + if let Some(v) = opts.length_threshold { + config.length_threshold = v; + } + if let Some(v) = opts.max_iterations { + config.max_iterations = v; + } + if let Some(v) = opts.splice_threshold { + config.splice_threshold = v; + } + if let Some(v) = opts.path_precision { + config.path_precision = Some(v); + } + if let Some(list) = opts.palette { + config.palette = list.iter().map(|s| parse_hex(s)).collect::>()?; + } + if let Some(v) = opts.max_colors { + config.max_colors = Some(v); + } + if let Some(v) = opts.optimize { + config.optimize = v; + } + Ok(config) +} + +fn to_svg(config: Config, img: ColorImage) -> Result { + config.build().map_err(err)?.to_svg(&img).map_err(err) +} + +/// Vectorize encoded image bytes (PNG/JPEG/GIF/BMP). Returns the SVG string. +#[wasm_bindgen] +pub fn vectorize_bytes(data: &[u8], options: JsValue) -> Result { + let config = config_from(options)?; + let img = image::ImageReader::new(Cursor::new(data)) + .with_guessed_format() + .map_err(err)? + .decode() + .map_err(|e| err(format!("failed to decode image: {e}")))? + .to_rgba8(); + let (width, height) = (img.width() as usize, img.height() as usize); + to_svg( + config, + ColorImage { + pixels: img.into_raw(), + width, + height, + }, + ) +} + +/// Vectorize a raw RGBA8 buffer (`width * height * 4` bytes). Returns the SVG. +#[wasm_bindgen] +pub fn vectorize_rgba( + data: Vec, + width: usize, + height: usize, + options: JsValue, +) -> Result { + if data.len() != width * height * 4 { + return Err(err(format!( + "rgba length {} != width*height*4 ({})", + data.len(), + width * height * 4 + ))); + } + let config = config_from(options)?; + to_svg( + config, + ColorImage { + pixels: data, + width, + height, + }, + ) +} diff --git a/nodejs/test.js b/nodejs/test.js new file mode 100644 index 0000000..0526203 --- /dev/null +++ b/nodejs/test.js @@ -0,0 +1,52 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vtracer = require('./index.js'); + +const SAMPLE = path.join(__dirname, '..', 'docs', 'assets', 'samples', 'tank-unit-preview.png'); +const data = fs.readFileSync(SAMPLE); + +// encoded bytes, default options +let svg = vtracer.convertBuffer(data); +assert(svg.includes(' all black +svg = vtracer.convertBuffer(data, { colorMode: 'bw' }); +assert(svg.includes('fill="#000000"'), 'bw produces black'); +console.log('convertBuffer bw:', (svg.match(/ 0, 'convertFileSync wrote file'); +console.log('convertFileSync wrote:', fs.statSync(out).size, 'bytes'); + +// error handling +assert.throws(() => vtracer.convertBuffer(data, { palette: ['nope'] }), /rrggbb/, 'bad palette rejected'); +assert.throws(() => vtracer.convertPixels(Buffer.alloc(8), 10, 10), /rgba length/, 'bad pixel length rejected'); +console.log('errors rejected OK'); + +console.log('ALL OK');