mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-16 09:06:07 -07:00
f4fe428038
Config(simplify=...) / property in Python; simplify option in Node, with the tolerance documented in .pyi, index.d.ts, and both READMEs. The Node README's option list also catches up with the clustering rename and the binary/watershed options, and test.js drops the stale colorMode key (silently ignored since the rename, so its bw assertion was testing the default path) and asserts simplify shrinks output.
193 lines
6.1 KiB
Rust
193 lines
6.1 KiB
Rust
//! 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 {
|
||
/// Region forming: "color-cluster" | "bw" | "watershed".
|
||
clustering: 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>,
|
||
/// Curve simplification tolerance in px (omit = off).
|
||
simplify: Option<f64>,
|
||
path_precision: Option<u32>,
|
||
palette: Option<Vec<String>>,
|
||
max_colors: Option<usize>,
|
||
optimize: Option<u8>,
|
||
/// Binary-mode fixed threshold (0..=255).
|
||
binary_threshold: Option<u8>,
|
||
/// Binary mode: use Bradley–Roth adaptive thresholding.
|
||
adaptive: Option<bool>,
|
||
/// Adaptive window side length in px (0 = auto).
|
||
adaptive_window: Option<u32>,
|
||
/// Adaptive sensitivity: percent below the local mean (default 15).
|
||
adaptive_t: Option<f64>,
|
||
/// Watershed clustering: hierarchy cut level (0..=255).
|
||
watershed_detail: 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.clustering {
|
||
config.clustering = 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.simplify {
|
||
config.simplify = Some(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;
|
||
}
|
||
if let Some(v) = opts.binary_threshold {
|
||
config.binary_threshold = v;
|
||
}
|
||
// Any adaptive tuning field (or `adaptive: true`) switches on Bradley–Roth.
|
||
if opts.adaptive == Some(true) || opts.adaptive_window.is_some() || opts.adaptive_t.is_some() {
|
||
config.binary_adaptive = true;
|
||
}
|
||
if let Some(v) = opts.adaptive_window {
|
||
config.binary_adaptive_window = v;
|
||
}
|
||
if let Some(v) = opts.adaptive_t {
|
||
config.binary_adaptive_t = v;
|
||
}
|
||
if let Some(v) = opts.watershed_detail {
|
||
config.watershed_detail = 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,
|
||
},
|
||
)
|
||
}
|