mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-15 08:35:56 -07:00
Add nodejs: WebAssembly Node package with no native dependency
New nodejs/ package: a wasm-bindgen crate (vtracer-wasm) built with wasm-pack that wraps the vtracer framework, plus a thin JS layer for file I/O. Image decoding (png/jpeg/gif/bmp via the image crate) runs in wasm too, so the package has zero native dependencies — no sharp, no node-gyp. No separate general-purpose wasm crate: the Node package directly owns and wraps the wasm. Excluded from the cargo workspace (wasm-bindgen cdylib), built with wasm-pack. JS API (camelCase options): convertBuffer, convertPixels, convertFile, convertFileSync — each taking an Options object (preset, colorMode, hierarchical/cutout mosaic, mode, palette, maxColors, optimize, ...). Ships index.d.ts types and a node smoke test. README updated. Verified: builds to wasm32-unknown-unknown; `node test.js` passes; output matches the CLI/Python bindings (253 paths on the tank sample).
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/pkg
|
||||
/target
|
||||
/node_modules
|
||||
Cargo.lock
|
||||
@@ -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 <tyt2y7@gmail.com>"]
|
||||
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
|
||||
@@ -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<void>` — 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
|
||||
```
|
||||
Vendored
+34
@@ -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<void>;
|
||||
|
||||
/** Synchronous {@link convertFile}. */
|
||||
export function convertFileSync(inputPath: string, outputPath: string, options?: Options): void;
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
hierarchical: Option<String>,
|
||||
mode: Option<String>,
|
||||
filter_speckle: Option<usize>,
|
||||
color_precision: Option<i32>,
|
||||
layer_difference: Option<i32>,
|
||||
corner_threshold: Option<i32>,
|
||||
length_threshold: Option<f64>,
|
||||
max_iterations: Option<usize>,
|
||||
splice_threshold: Option<i32>,
|
||||
path_precision: Option<u32>,
|
||||
palette: Option<Vec<String>>,
|
||||
max_colors: Option<usize>,
|
||||
optimize: Option<u8>,
|
||||
/// One of "bw" | "poster" | "photo"; applied before the other fields.
|
||||
preset: Option<String>,
|
||||
}
|
||||
|
||||
fn err(msg: impl std::fmt::Display) -> JsValue {
|
||||
JsValue::from_str(&msg.to_string())
|
||||
}
|
||||
|
||||
fn parse_hex(token: &str) -> Result<Color, JsValue> {
|
||||
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<usize>| {
|
||||
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<Config, JsValue> {
|
||||
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::<Result<_, _>>()?;
|
||||
}
|
||||
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<String, JsValue> {
|
||||
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<String, JsValue> {
|
||||
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<u8>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
options: JsValue,
|
||||
) -> Result<String, JsValue> {
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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('<svg') && svg.includes('<path'), 'default convertBuffer');
|
||||
console.log('convertBuffer default:', (svg.match(/<path/g) || []).length, 'paths');
|
||||
|
||||
// options: bw preset -> all black
|
||||
svg = vtracer.convertBuffer(data, { colorMode: 'bw' });
|
||||
assert(svg.includes('fill="#000000"'), 'bw produces black');
|
||||
console.log('convertBuffer bw:', (svg.match(/<path/g) || []).length, 'paths');
|
||||
|
||||
// options: mosaic + polygon + palette
|
||||
svg = vtracer.convertBuffer(data, { hierarchical: 'cutout', mode: 'polygon', palette: ['#000000', '#ffffff'], optimize: 2 });
|
||||
assert(svg.includes('<svg'), 'mosaic+palette');
|
||||
console.log('convertBuffer cutout/polygon/palette:', (svg.match(/<path/g) || []).length, 'paths');
|
||||
|
||||
// preset
|
||||
svg = vtracer.convertBuffer(data, { preset: 'poster' });
|
||||
console.log('convertBuffer poster:', (svg.match(/<path/g) || []).length, 'paths');
|
||||
|
||||
// raw pixels: 20x20, left red / right blue
|
||||
const w = 20, h = 20;
|
||||
const rgba = Buffer.alloc(w * h * 4);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
const [r, g, b] = x < w / 2 ? [220, 40, 40] : [40, 40, 220];
|
||||
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = 255;
|
||||
}
|
||||
svg = vtracer.convertPixels(rgba, w, h);
|
||||
assert(svg.includes('<svg'), 'convertPixels');
|
||||
console.log('convertPixels:', (svg.match(/<path/g) || []).length, 'paths');
|
||||
|
||||
// file I/O
|
||||
const out = path.join(require('os').tmpdir(), 'vtracer_node_out.svg');
|
||||
vtracer.convertFileSync(SAMPLE, out, { mode: 'spline' });
|
||||
assert(fs.statSync(out).size > 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');
|
||||
Reference in New Issue
Block a user