Publish desktop updater 1.0.0-alpha.3

This commit is contained in:
VTracer Release Bot
2026-08-02 01:15:17 +01:00
commit daa866862b
152 changed files with 74300 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/pkg
/target
/node_modules
Cargo.lock
# npm auth token — per-project, never commit
.npmrc
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
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.3", 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 (webp via image-webp).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
[profile.release]
opt-level = "s"
lto = true
+56
View File
@@ -0,0 +1,56 @@
# 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 @visioncortex/vtracer
```
## Usage
```js
const vtracer = require('@visioncortex/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, { clustering: '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), `clustering`
(`"color-cluster" | "bw" | "watershed"`), `hierarchical` (`"stacked" | "cutout"`
for the seam-free mosaic), `mode` (`"pixel" | "polygon" | "spline"`),
`filterSpeckle`, `colorPrecision`, `layerDifference`, `cornerThreshold`,
`lengthThreshold`, `maxIterations`, `spliceThreshold`, `simplify` (curve
simplification tolerance in px, try 1–2.5), `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`), `binaryThreshold` /
`adaptive` / `adaptiveWindow` / `adaptiveT` (binary mode), `watershedDetail`
(0..=255).
## 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
```
+47
View File
@@ -0,0 +1,47 @@
/** 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';
/** Region forming: hierarchical color clustering (default), binary threshold, or watershed. */
clustering?: 'color-cluster' | 'bw' | 'watershed';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
colorPrecision?: number;
layerDifference?: number;
cornerThreshold?: number;
lengthThreshold?: number;
maxIterations?: number;
spliceThreshold?: number;
/** Curve simplification tolerance in px (omit = off; try 1-2.5). */
simplify?: 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;
/** Binary mode (`clustering: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
binaryThreshold?: number;
/** Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting). */
adaptive?: boolean;
/** Adaptive window side length in px; 0 = auto (~1/8 of the shorter side). */
adaptiveWindow?: number;
/** Adaptive sensitivity: percent below the local mean (default 15). */
adaptiveT?: number;
/** Watershed clustering: hierarchy cut level 0..=255 (higher = more regions, default 128). */
watershedDetail?: 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;
+49
View File
@@ -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 };
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.3",
"description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.",
"main": "index.js",
"types": "index.d.ts",
"publishConfig": {
"access": "public"
},
"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",
"publish:local": "node scripts/publish.mjs",
"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"
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Build the wasm package and publish it, by default to a local npm registry
// (e.g. a Verdaccio instance at http://localhost:4873).
//
// node scripts/publish.mjs # publish to the local registry
// node scripts/publish.mjs --dry-run # build + pack, don't publish
// node scripts/publish.mjs --registry=http://... # override the registry
// NPM_REGISTRY=http://... node scripts/publish.mjs
//
// The registry may also be given via the NPM_REGISTRY env var.
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const regArg = args.find((a) => a.startsWith('--registry='));
const registry =
(regArg && regArg.slice('--registry='.length)) ||
process.env.NPM_REGISTRY ||
'http://localhost:4873';
function run(cmd, cmdArgs) {
console.log(`\n$ ${cmd} ${cmdArgs.join(' ')}`);
execFileSync(cmd, cmdArgs, { stdio: 'inherit', cwd: pkgDir });
}
// 1. Fresh wasm build (regenerates pkg/).
run('wasm-pack', ['build', '--target', 'nodejs', '--out-dir', 'pkg']);
// 2. Sanity check before publishing.
run('node', ['test.js']);
// 3. Publish (or dry-run) to the chosen registry.
const publishArgs = ['publish', '--registry', registry];
if (dryRun) publishArgs.push('--dry-run');
run('npm', publishArgs);
console.log(`\n✔ ${dryRun ? 'dry-run for' : 'published to'} ${registry}`);
+192
View File
@@ -0,0 +1,192 @@
//! 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,
},
)
}
+60
View File
@@ -0,0 +1,60 @@
'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 clustering -> all black
svg = vtracer.convertBuffer(data, { clustering: 'bw' });
assert(svg.includes('fill="#000000"'), 'bw produces black');
console.log('convertBuffer bw:', (svg.match(/<path/g) || []).length, 'paths');
// curve simplification shrinks the output
{
const plain = vtracer.convertBuffer(data);
const simplified = vtracer.convertBuffer(data, { simplify: 2 });
assert(simplified.length < plain.length, 'simplify shrinks output');
console.log('convertBuffer simplify:', plain.length, '->', simplified.length, 'bytes');
}
// 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');