mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-10-02 08:51:21 -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:
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user