@image-tracer-ts/browser
Version:
Platform-specific bindings for image-tracer-ts. Turn images into SVG files in browsers.
1,188 lines (1,178 loc) • 382 kB
JavaScript
var RgbColorData;
(function (RgbColorData) {
function toString(c) {
return `RgbColor(${c.r},${c.g},${c.b})`;
}
RgbColorData.toString = toString;
})(RgbColorData || (RgbColorData = {}));
class RgbColor {
r;
g;
b;
a;
/**
* If the `a` value is below this value, a color is considered invisible.
*/
static MINIMUM_A = 13; // that is 0.05
constructor(r = 0, g = 0, b = 0, a = 255) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
static fromRgbColorData(c) {
return new RgbColor(c.r, c.g, c.b, c.a ?? 255);
}
static createRandomColor() {
const color = new RgbColor();
color.randomize();
return color;
}
static fromPixelArray(pixelData, pixelIndex, isRgba = true) {
const color = new RgbColor();
color.setFromPixelArray(pixelData, pixelIndex, isRgba);
return color;
}
static fromHex(hex) {
const values = hex.substring(1).match(/.{1,2}/g)?.map(n => parseInt(n, 16));
return RgbColor.fromPixelArray(values, 0, values.length > 3);
}
static buildColorAverage(counter) {
const r = Math.floor(counter.r / counter.n);
const g = Math.floor(counter.g / counter.n);
const b = Math.floor(counter.b / counter.n);
const a = Math.floor(counter.a / counter.n);
return new RgbColor(r, g, b, a);
}
isInvisible() {
return this.a < RgbColor.MINIMUM_A;
}
hasOpacity() {
return this.a < 255;
}
setFromColorCounts(counter) {
this.r = Math.floor(counter.r / counter.n);
this.g = Math.floor(counter.g / counter.n);
this.b = Math.floor(counter.b / counter.n);
this.a = Math.floor(counter.a / counter.n);
}
randomize() {
this.r = Math.floor(Math.random() * 256);
this.g = Math.floor(Math.random() * 256);
this.b = Math.floor(Math.random() * 256);
this.a = Math.floor(Math.random() * 128) + 128;
}
setFromPixelArray(pixelData, pixelIndex, isRgba = true) {
const pixelWidth = isRgba ? 4 : 3;
const offset = pixelIndex * pixelWidth;
this.r = pixelData[offset + 0];
this.g = pixelData[offset + 1];
this.b = pixelData[offset + 2];
this.a = isRgba ? pixelData[offset + 3] : 255;
}
get [Symbol.toStringTag]() {
return `RgbaColor(${this.r},${this.g},${this.b},${this.a})`;
}
calculateDistanceToPixelInArray(pixelData, pixelIndex, isRgba = true) {
const a = isRgba ? pixelData[pixelIndex + 3] : 255;
// In my experience, https://en.wikipedia.org/wiki/Rectilinear_distance works better than https://en.wikipedia.org/wiki/Euclidean_distance
return Math.abs(this.r - pixelData[pixelIndex])
+ Math.abs(this.g - pixelData[pixelIndex + 1])
+ Math.abs(this.b - pixelData[pixelIndex + 2])
+ Math.abs(this.a - a);
}
equals(color) {
return this.r === color.r
&& this.g === color.g
&& this.b === color.b
&& this.a === (color.a ?? 255);
}
toCssColor() {
return !this.hasOpacity() ? `rgb(${this.r},${this.g},${this.b})` : `rgba(${this.r},${this.g},${this.b},${this.a})`;
}
toCssColorHex() {
const int = this.toInt32();
let hex = int.toString(16);
const leadingZeros = (this.hasOpacity() ? 8 : 6) - hex.length;
if (leadingZeros > 0) {
hex = '0'.repeat(leadingZeros) + hex;
}
return '#' + hex.toUpperCase();
}
toInt32() {
if (!this.hasOpacity()) {
return ((this.r << 16) | (this.g << 8) | (this.b)) >>> 0; // keep unsigned
}
return ((this.r << 24) | (this.g << 16) | (this.b << 8) | this.a) >>> 0; // keep unsigned
}
}
var ColorDistanceBuffering;
(function (ColorDistanceBuffering) {
ColorDistanceBuffering["OFF"] = "off";
ColorDistanceBuffering["ON"] = "on";
ColorDistanceBuffering["REASONABLE"] = "reasonable";
})(ColorDistanceBuffering || (ColorDistanceBuffering = {}));
class ColorIndex {
rows; // palette color index for each pixel in the image
palette;
options;
verbose;
constructor(imageData, options, quantizeFunction) {
this.options = options;
this.verbose = options.verbose ?? false;
this.palette = this.buildPalette(imageData, quantizeFunction);
this.verbose && console.time(' - Color Quantization');
this.rows = this.buildColorData(imageData, this.palette);
this.verbose && console.timeEnd(' - Color Quantization');
}
/**
* @param imageData
* @returns
*/
buildPalette(imageData, quantizeFunction) {
const numberOfColors = Math.max(this.options.numberOfColors, 2);
const palette = quantizeFunction(imageData, numberOfColors);
if (this.options.verbose) {
console.log(`Created palette with ${palette.length} colors.`);
}
return palette.map(c => (c instanceof RgbColor) ? c : RgbColor.fromRgbColorData(c));
}
/**
* Using a form of k-means clustering repeated options.colorClusteringCycles times. http://en.wikipedia.org/wiki/Color_quantization
*
*
* @param imageData
* @returns
*/
buildColorData(imageData, palette) {
let imageColorIndex;
const numberOfCycles = Math.max(this.options.colorClusteringCycles, 1);
for (let cycle = 1; cycle <= numberOfCycles; cycle++) {
const isLastCycle = cycle === numberOfCycles;
const nextImageColorIndex = this.runClusteringCycle(imageData, palette, isLastCycle);
const isFinished = isLastCycle || (imageColorIndex && this.colorIndexesEqual(imageColorIndex, nextImageColorIndex));
imageColorIndex = nextImageColorIndex;
if (isFinished) {
this.options.verbose && console.log(`Ran ${cycle} clustering cycles`);
break;
}
}
return imageColorIndex;
}
runClusteringCycle(imageData, palette, isLastCycle) {
const colorIndex = this.buildImageColorIndex(imageData, palette);
const colorCounts = this.buildColorCounts(imageData, colorIndex, palette.length);
const numPixels = imageData.width * imageData.height;
this.adjustPaletteToColorAverages(palette, colorCounts, numPixels, isLastCycle);
return colorIndex;
}
colorIndexesEqual(i1, i2) {
if (i1.length !== i2.length) {
return false;
}
for (let rowIx = 0; rowIx < i1.length; rowIx++) {
const row1 = i1[rowIx];
const row2 = i2[rowIx];
if (row1.length !== row2.length) {
return false;
}
for (let colIx = 0; colIx < row1.length; colIx++) {
if (row1[colIx] !== row2[colIx]) {
return false;
}
}
}
return true;
}
adjustPaletteToColorAverages(palette, colorCounters, numPixels, isLastCycle) {
for (let k = 0; k < palette.length; k++) {
const counter = colorCounters[k];
const colorBelowThreshold = this.options.minColorQuota > 0 && counter.n / numPixels < this.options.minColorQuota;
if (colorBelowThreshold && !isLastCycle) {
palette[k].randomize();
}
else if (counter.n > 0) {
palette[k].setFromColorCounts(counter);
}
}
}
/**
* Maps each pixel in the image to the palette index of the closest color.
*
* @param imageData
* @param palette
* @returns
*/
buildImageColorIndex(imageData, palette) {
const bufferingMode = this.options.colorDistanceBuffering;
const useBuffer = bufferingMode === ColorDistanceBuffering.ON ||
(bufferingMode === ColorDistanceBuffering.REASONABLE && palette.length >= 32);
return useBuffer ?
this.buildImageColorIndexBuffered(imageData, palette) :
this.buildImageColorIndexUnbuffered(imageData, palette);
}
buildImageColorIndexUnbuffered(imageData, palette) {
const imageColorIndex = this.initColorIndexArray(imageData.width, imageData.height);
for (let h = 0; h < imageData.height; h++) {
for (let w = 0; w < imageData.width; w++) {
const pixelOffset = (h * imageData.width + w) * 4;
const closestColorIx = this.findClosestPaletteColorIx(imageData, pixelOffset, palette);
imageColorIndex[h + 1][w + 1] = closestColorIx;
}
}
return imageColorIndex;
}
buildImageColorIndexBuffered(imageData, palette) {
const imageColorIndex = this.initColorIndexArray(imageData.width, imageData.height);
const closestColorMap = [];
let skips = 0, distinctValues = 0;
for (let h = 0; h < imageData.height; h++) {
for (let w = 0; w < imageData.width; w++) {
const pixelOffset = (h * imageData.width + w) * 4;
const colorId = this.getPixelColorId(imageData, pixelOffset);
if (closestColorMap[colorId] !== undefined) {
skips++;
}
else {
closestColorMap[colorId] = this.findClosestPaletteColorIx(imageData, pixelOffset, palette);
distinctValues++;
}
imageColorIndex[h + 1][w + 1] = closestColorMap[colorId];
}
}
this.verbose && console.log(`Buffered ${distinctValues} colors to skip ${skips} comparisons (`, Math.round(100 * skips / (skips + distinctValues)), '%)');
return imageColorIndex;
}
getPixelColorId(imageData, pixelOffset) {
return ((imageData.data[pixelOffset] << 24)
| (imageData.data[pixelOffset + 1] << 16)
| (imageData.data[pixelOffset + 2] << 8)
| (imageData.data[pixelOffset + 3])) >>> 0;
}
initColorIndexArray(imgWidth, imgHeight) {
const imageColorIndex = [];
for (let h = 0; h < imgHeight + 2; h++) {
imageColorIndex[h] = new Array(imgWidth + 2).fill(-1);
}
return imageColorIndex;
}
buildColorCounts(imageData, imageColorIndex, numberOfColors) {
const colorCounts = this.initColorCounts(numberOfColors);
for (let h = 0; h < imageData.height; h++) {
for (let w = 0; w < imageData.width; w++) {
const closestColorIx = imageColorIndex[h + 1][w + 1];
const colorCounter = colorCounts[closestColorIx];
const pixelOffset = (h * imageData.width + w) * 4;
colorCounter.r += imageData.data[pixelOffset];
colorCounter.g += imageData.data[pixelOffset + 1];
colorCounter.b += imageData.data[pixelOffset + 2];
colorCounter.a += imageData.data[pixelOffset + 3];
colorCounter.n++;
}
}
return colorCounts;
}
initColorCounts(numberOfColors) {
const colorCounts = [];
for (let i = 0; i < numberOfColors; i++) {
colorCounts[i] = { r: 0, g: 0, b: 0, a: 0, n: 0 };
}
return colorCounts;
}
/**
* find closest color from palette by measuring (rectilinear) color distance between this pixel and all palette colors
* @param palette
* @param color
* @param pixelOffset
* @returns
*/
findClosestPaletteColorIx(imageData, pixelOffset, palette) {
let closestColorIx = 0;
let closestDistance = 1024; // 4 * 256 is the maximum RGBA distance
for (let colorIx = 0; colorIx < palette.length; colorIx++) {
const color = palette[colorIx];
const distance = color.calculateDistanceToPixelInArray(imageData.data, pixelOffset);
if (distance >= closestDistance) {
continue;
}
closestDistance = distance;
closestColorIx = colorIx;
}
return closestColorIx;
}
}
var Trajectory;
(function (Trajectory) {
Trajectory[Trajectory["RIGHT"] = 0] = "RIGHT";
Trajectory[Trajectory["DOWN_RIGHT"] = 1] = "DOWN_RIGHT";
Trajectory[Trajectory["DOWN"] = 2] = "DOWN";
Trajectory[Trajectory["DOWN_LEFT"] = 3] = "DOWN_LEFT";
Trajectory[Trajectory["LEFT"] = 4] = "LEFT";
Trajectory[Trajectory["UP_LEFT"] = 5] = "UP_LEFT";
Trajectory[Trajectory["UP"] = 6] = "UP";
Trajectory[Trajectory["UP_RIGHT"] = 7] = "UP_RIGHT";
Trajectory[Trajectory["NONE"] = 0] = "NONE";
})(Trajectory || (Trajectory = {}));
var InterpolationMode;
(function (InterpolationMode) {
InterpolationMode["OFF"] = "off";
InterpolationMode["INTERPOLATE"] = "interpolate";
})(InterpolationMode || (InterpolationMode = {}));
class PointInterpolator {
interpolate(mode, paths, enhanceRightAngle) {
const interpolatedPaths = [];
for (const path of paths) {
const interpolatedPoints = this.interpolatePointsUsingMode(mode, path.points, enhanceRightAngle);
const interpolatedPath = {
points: interpolatedPoints,
boundingBox: path.boundingBox,
childHoles: path.childHoles,
isHole: path.isHole
};
interpolatedPaths.push(interpolatedPath);
}
return interpolatedPaths;
}
interpolatePointsUsingMode(mode, edgePoints, enhanceRightAngle) {
switch (mode) {
case InterpolationMode.OFF:
return edgePoints.map((ep, ix) => this.trajectoryPointFromEdgePoint(edgePoints, ix));
default:
case InterpolationMode.INTERPOLATE:
return this.buildInterpolatedPoints(edgePoints, enhanceRightAngle);
}
}
trajectoryPointFromEdgePoint(points, pointIx) {
const edgePoint = points[pointIx];
const nextIx = (pointIx + 1) % points.length;
const nextPoint = points[nextIx];
return {
x: edgePoint.x,
y: edgePoint.y,
data: this.geTrajectory(edgePoint.x, edgePoint.y, nextPoint.x, nextPoint.y)
};
}
buildInterpolatedPoints(edgePoints, enhanceRightAngle) {
const interpolatedPoints = [];
for (let pointIx = 0; pointIx < edgePoints.length; pointIx++) {
if (enhanceRightAngle && this.isRightAngle(edgePoints, pointIx)) {
const cornerPoint = this.buildCornerPoint(edgePoints, pointIx);
this.updateLastPointTrajectory(interpolatedPoints, edgePoints[pointIx]);
interpolatedPoints.push(cornerPoint);
}
const midPoint = this.interpolateNextTwoPoints(edgePoints, pointIx);
interpolatedPoints.push(midPoint);
}
return interpolatedPoints;
}
updateLastPointTrajectory(points, referencePoint) {
if (points.length === 0) {
return;
}
const lastPointIx = points.length - 1;
const lastPoint = points[lastPointIx];
lastPoint.data = this.geTrajectory(lastPoint.x, lastPoint.y, referencePoint.x, referencePoint.y);
}
interpolateNextTwoPoints(points, pointIx) {
const totalPoints = points.length;
const nextIx1 = (pointIx + 1) % totalPoints;
const nextIx2 = (pointIx + 2) % totalPoints;
const currentPoint = points[pointIx];
const nextPoint = points[nextIx1];
const nextNextPoint = points[nextIx2];
const midX = (currentPoint.x + nextPoint.x) / 2;
const midY = (currentPoint.y + nextPoint.y) / 2;
const nextMidX = (nextPoint.x + nextNextPoint.x) / 2;
const nextMidY = (nextPoint.y + nextNextPoint.y) / 2;
return {
x: midX,
y: midY,
data: this.geTrajectory(midX, midY, nextMidX, nextMidY)
};
}
isRightAngle(points, pointIx) {
const totalPoints = points.length;
const currentPoint = points[pointIx];
const nextIx1 = (pointIx + 1) % totalPoints;
const nextIx2 = (pointIx + 2) % totalPoints;
const prevIx1 = (pointIx - 1 + totalPoints) % totalPoints;
const prevIx2 = (pointIx - 2 + totalPoints) % totalPoints;
return ((currentPoint.x === points[prevIx2].x) &&
(currentPoint.x === points[prevIx1].x) &&
(currentPoint.y === points[nextIx1].y) &&
(currentPoint.y === points[nextIx2].y)) || ((currentPoint.y === points[prevIx2].y) &&
(currentPoint.y === points[prevIx1].y) &&
(currentPoint.x === points[nextIx1].x) &&
(currentPoint.x === points[nextIx2].x));
}
buildCornerPoint(points, pointIx) {
const nextIx1 = (pointIx + 1) % points.length;
const currentPoint = points[pointIx];
const nextPoint = points[nextIx1];
const midX = (currentPoint.x + nextPoint.x) / 2;
const midY = (currentPoint.y + nextPoint.y) / 2;
const trajectory = this.geTrajectory(currentPoint.x, currentPoint.y, midX, midY);
return {
x: currentPoint.x,
y: currentPoint.y,
data: trajectory,
};
}
geTrajectory(x1, y1, x2, y2) {
if (x1 < x2) {
if (y1 < y2) {
return Trajectory.DOWN_RIGHT;
}
if (y1 > y2) {
return Trajectory.UP_RIGHT;
}
return Trajectory.RIGHT;
}
else if (x1 > x2) {
if (y1 < y2) {
return Trajectory.DOWN_LEFT;
}
if (y1 > y2) {
return Trajectory.UP_LEFT;
}
return Trajectory.LEFT;
}
else {
if (y1 < y2) {
return Trajectory.DOWN;
}
if (y1 > y2) {
return Trajectory.UP;
}
}
return Trajectory.NONE;
}
}
var TrimMode;
(function (TrimMode) {
TrimMode["OFF"] = "off";
TrimMode["KEEP_RATIO"] = "ratio";
TrimMode["ALL"] = "all";
})(TrimMode || (TrimMode = {}));
var FillStyle;
(function (FillStyle) {
FillStyle["FILL"] = "fill";
FillStyle["STROKE"] = "stroke";
FillStyle["STROKE_FILL"] = "stroke+fill";
})(FillStyle || (FillStyle = {}));
const SvgDrawerDefaultOptions = {
strokeWidth: 1,
lineFilter: false,
scale: 1,
decimalPlaces: 1,
viewBox: true,
desc: false,
segmentEndpointRadius: 0,
curveControlPointRadius: 0,
fillStyle: FillStyle.STROKE_FILL,
trim: TrimMode.OFF,
};
var CreatePaletteMode;
(function (CreatePaletteMode) {
CreatePaletteMode["GENERATE"] = "generate";
CreatePaletteMode["SAMPLE"] = "sample";
CreatePaletteMode["SCAN"] = "scan";
CreatePaletteMode["PALETTE"] = "palette";
})(CreatePaletteMode || (CreatePaletteMode = {}));
var LayeringMode;
(function (LayeringMode) {
LayeringMode[LayeringMode["SEQUENTIAL"] = 1] = "SEQUENTIAL";
LayeringMode[LayeringMode["PARALLEL"] = 2] = "PARALLEL";
})(LayeringMode || (LayeringMode = {}));
var Options;
(function (Options) {
/**
* Create full options object from partial
*/
function buildFrom(options) {
return Object.assign({}, defaultOptions, options ?? {});
}
Options.buildFrom = buildFrom;
const defaultOptions = Object.assign(SvgDrawerDefaultOptions, {
// Tracing
lineErrorMargin: 1,
curveErrorMargin: 1,
minShapeOutline: 8,
enhanceRightAngles: true,
// Color quantization
colorSamplingMode: CreatePaletteMode.SCAN,
palette: null,
numberOfColors: 16,
minColorQuota: 0,
colorClusteringCycles: 3,
colorDistanceBuffering: ColorDistanceBuffering.REASONABLE,
// Layering method
layeringMode: LayeringMode.PARALLEL,
interpolation: InterpolationMode.INTERPOLATE,
// Blur
blurRadius: 0,
blurDelta: 20,
sharpen: false,
sharpenThreshold: 20,
});
const asPresets = (o) => o;
Options.Presets = asPresets({
default: defaultOptions,
posterized1: { colorSamplingMode: CreatePaletteMode.GENERATE, numberOfColors: 2 },
posterized2: { numberOfColors: 4, blurRadius: 5 },
curvy: { lineErrorMargin: 0.01, lineFilter: true, enhanceRightAngles: false },
sharp: { curveErrorMargin: 0.01, lineFilter: false },
detailed: { minShapeOutline: 0, decimalPlaces: 2, lineErrorMargin: 0.5, curveErrorMargin: 0.5, numberOfColors: 64 },
smoothed: { blurRadius: 5, blurDelta: 64 },
grayscale: { colorSamplingMode: CreatePaletteMode.GENERATE, colorClusteringCycles: 1, numberOfColors: 7 },
fixedpalette: { colorSamplingMode: CreatePaletteMode.GENERATE, colorClusteringCycles: 1, numberOfColors: 27 },
randomsampling1: { colorSamplingMode: CreatePaletteMode.SAMPLE, numberOfColors: 8 },
randomsampling2: { colorSamplingMode: CreatePaletteMode.SAMPLE, numberOfColors: 64 },
artistic1: { colorSamplingMode: CreatePaletteMode.GENERATE, colorClusteringCycles: 1, minShapeOutline: 0, blurRadius: 5, blurDelta: 64, lineErrorMargin: 0.01, lineFilter: true, numberOfColors: 16, strokeWidth: 2 },
artistic2: { curveErrorMargin: 0.01, colorSamplingMode: CreatePaletteMode.GENERATE, colorClusteringCycles: 1, numberOfColors: 4, strokeWidth: 0 },
artistic3: { curveErrorMargin: 10, lineErrorMargin: 10, numberOfColors: 8 },
artistic4: { curveErrorMargin: 10, lineErrorMargin: 10, numberOfColors: 64, blurRadius: 5, blurDelta: 256, strokeWidth: 2 },
posterized3: {
lineErrorMargin: 1, curveErrorMargin: 1, minShapeOutline: 20, enhanceRightAngles: true, colorSamplingMode: CreatePaletteMode.GENERATE, numberOfColors: 3,
minColorQuota: 0, colorClusteringCycles: 3, blurRadius: 3, blurDelta: 20, strokeWidth: 0, lineFilter: false,
decimalPlaces: 1, palette: [{ r: 0, g: 0, b: 100, a: 255 }, { r: 255, g: 255, b: 255, a: 255 }]
}
});
})(Options || (Options = {}));
const SPEC_PALETTE = [
{ r: 0, g: 0, b: 0, a: 255 }, { r: 128, g: 128, b: 128, a: 255 }, { r: 0, g: 0, b: 128, a: 255 }, { r: 64, g: 64, b: 128, a: 255 },
{ r: 192, g: 192, b: 192, a: 255 }, { r: 255, g: 255, b: 255, a: 255 }, { r: 128, g: 128, b: 192, a: 255 }, { r: 0, g: 0, b: 192, a: 255 },
{ r: 128, g: 0, b: 0, a: 255 }, { r: 128, g: 64, b: 64, a: 255 }, { r: 128, g: 0, b: 128, a: 255 }, { r: 168, g: 168, b: 168, a: 255 },
{ r: 192, g: 128, b: 128, a: 255 }, { r: 192, g: 0, b: 0, a: 255 }, { r: 255, g: 255, b: 255, a: 255 }, { r: 0, g: 128, b: 0, a: 255 }
];
class DivRenderer {
// Helper function: Drawing all edge node layers into a container
drawLayersToDiv(edgeRasters, scale, parentId) {
scale = scale || 1;
var w, h, i, j, k;
// Preparing container
var div;
if (parentId) {
div = document.getElementById(parentId);
if (!div) {
div = document.createElement('div');
div.id = parentId;
document.body.appendChild(div);
}
}
else {
div = document.createElement('div');
document.body.appendChild(div);
}
// Layers loop
for (k in edgeRasters) {
if (!edgeRasters.hasOwnProperty(k)) {
continue;
}
// width, height
w = edgeRasters[k][0].length;
h = edgeRasters[k].length;
// Creating new canvas for every layer
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const context = this.getCanvasContext(canvas);
// Drawing
const palette = SPEC_PALETTE;
for (j = 0; j < h; j++) {
for (i = 0; i < w; i++) {
const colorIndex = edgeRasters[k][j][i] % palette.length;
const color = palette[colorIndex];
context.fillStyle = this.toRgbaLiteral(color);
context.fillRect(i * scale, j * scale, scale, scale);
}
}
// Appending canvas to container
div.appendChild(canvas);
}
}
// Convert color object to rgba string
toRgbaLiteral(c) {
return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + c.a + ')';
}
// TODO check duplication
getCanvasContext(canvas) {
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not read canvas');
}
return context;
}
}
class EdgeRasterBuilder {
/**
*
* Builds one layer for each color in the given color index.
*
* @param colorIndex
* @returns
*/
static buildForColors(colorIndex) {
// Creating layers for each indexed color in arr
const rows = colorIndex.rows;
const height = rows.length;
const width = rows[0].length;
// Create layers
const edgeRasters = [];
for (let colorId = 0; colorId < colorIndex.palette.length; colorId++) {
edgeRasters[colorId] = [];
for (let h = 0; h < height; h++) {
edgeRasters[colorId][h] = new Array(width).fill(0);
}
}
// Looping through all pixels and calculating edge node type
let n1, n2, n3, n4, n5, n6, n7, n8;
for (let h = 1; h < height - 1; h++) {
for (let w = 1; w < width - 1; w++) {
// This pixel's indexed color
const colorId = rows[h][w];
/**
* n1 n2 n3
* n4 n5
* n6 n7 n8
*/
n1 = rows[h - 1][w - 1] === colorId ? 1 : 0;
n2 = rows[h - 1][w] === colorId ? 1 : 0;
n3 = rows[h - 1][w + 1] === colorId ? 1 : 0;
n4 = rows[h][w - 1] === colorId ? 1 : 0;
n5 = rows[h][w + 1] === colorId ? 1 : 0;
n6 = rows[h + 1][w - 1] === colorId ? 1 : 0;
n7 = rows[h + 1][w] === colorId ? 1 : 0;
n8 = rows[h + 1][w + 1] === colorId ? 1 : 0;
// this pixel's type and looking back on previous pixels
const edgeRaster = edgeRasters[colorId];
edgeRaster[h + 1][w + 1] = 1 + n5 * 2 + n8 * 4 + n7 * 8;
if (!n4) {
edgeRaster[h + 1][w] = 0 + 2 + n7 * 4 + n6 * 8;
}
if (!n2) {
edgeRaster[h][w + 1] = 0 + n3 * 2 + n5 * 4 + 8;
}
if (!n1) {
edgeRaster[h][w] = 0 + n2 * 2 + 4 + n4 * 8;
}
}
}
return edgeRasters;
}
// 2. Layer separation and edge detection
// Edge node types ( ▓: this layer or 1; ░: not this layer or 0 )
// 12 ░░ ▓░ ░▓ ▓▓ ░░ ▓░ ░▓ ▓▓ ░░ ▓░ ░▓ ▓▓ ░░ ▓░ ░▓ ▓▓
// 48 ░░ ░░ ░░ ░░ ░▓ ░▓ ░▓ ░▓ ▓░ ▓░ ▓░ ▓░ ▓▓ ▓▓ ▓▓ ▓▓
// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
static buildForColor(colorData, colorId) {
// Creating layers for each indexed color in arr
const rows = colorData.rows;
const height = rows.length;
const width = rows[0].length;
// Create layer
const edgeRaster = [];
for (let j = 0; j < height; j++) {
edgeRaster[j] = new Array(width).fill(0);
}
// Looping through all pixels and calculating edge node type
for (let h = 1; h < height; h++) {
for (let w = 1; w < width; w++) {
/*
* current pixel is at 4:
* ░░ of 1 2
* ░▓ 8 4
*/
edgeRaster[h][w] = ((rows[h - 1][w - 1] === colorId ? 1 : 0) +
(rows[h - 1][w] === colorId ? 2 : 0) +
(rows[h][w - 1] === colorId ? 8 : 0) +
(rows[h][w] === colorId ? 4 : 0));
}
}
return edgeRaster;
}
}
// Lookup tables for pathscan
// pathscan_combined_lookup[ arr[py][px] ][ dir ] = [nextarrpypx, nextdir, deltapx, deltapy];
const PATH_SCAN_COMBINED_LOOKUP = [
[[-1, -1, -1, -1], [-1, -1, -1, -1], [-1, -1, -1, -1], [-1, -1, -1, -1]],
[[0, 1, 0, -1], [-1, -1, -1, -1], [-1, -1, -1, -1], [0, 2, -1, 0]],
[[-1, -1, -1, -1], [-1, -1, -1, -1], [0, 1, 0, -1], [0, 0, 1, 0]],
[[0, 0, 1, 0], [-1, -1, -1, -1], [0, 2, -1, 0], [-1, -1, -1, -1]],
[[-1, -1, -1, -1], [0, 0, 1, 0], [0, 3, 0, 1], [-1, -1, -1, -1]],
[[13, 3, 0, 1], [13, 2, -1, 0], [7, 1, 0, -1], [7, 0, 1, 0]],
[[-1, -1, -1, -1], [0, 1, 0, -1], [-1, -1, -1, -1], [0, 3, 0, 1]],
[[0, 3, 0, 1], [0, 2, -1, 0], [-1, -1, -1, -1], [-1, -1, -1, -1]],
[[0, 3, 0, 1], [0, 2, -1, 0], [-1, -1, -1, -1], [-1, -1, -1, -1]],
[[-1, -1, -1, -1], [0, 1, 0, -1], [-1, -1, -1, -1], [0, 3, 0, 1]],
[[11, 1, 0, -1], [14, 0, 1, 0], [14, 3, 0, 1], [11, 2, -1, 0]],
[[-1, -1, -1, -1], [0, 0, 1, 0], [0, 3, 0, 1], [-1, -1, -1, -1]],
[[0, 0, 1, 0], [-1, -1, -1, -1], [0, 2, -1, 0], [-1, -1, -1, -1]],
[[-1, -1, -1, -1], [-1, -1, -1, -1], [0, 1, 0, -1], [0, 0, 1, 0]],
[[0, 1, 0, -1], [-1, -1, -1, -1], [-1, -1, -1, -1], [0, 2, -1, 0]],
[[-1, -1, -1, -1], [-1, -1, -1, -1], [-1, -1, -1, -1], [-1, -1, -1, -1]] // arr[py][px]===15 is invalid
];
class AreaScanner {
/**
* 3. Walking through an edge node array, discarding edge node types 0 and 15 and creating paths from the rest.
* Walk directions: 0 > ; 1 ^ ; 2 < ; 3 v
* @param edgeRaster
* @returns
*/
scan(edgeRaster, pathMinLength) {
const width = edgeRaster[0].length;
const height = edgeRaster.length;
const paths = [];
let pathIx = 0;
for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) {
const edge = edgeRaster[h][w];
/**
* 12 ░░ ▓▓
* 84 ░▓ ▓░
* 4 11
*/
if (edge !== 4 && edge !== 11) {
// Other values are not important
continue;
}
// Init
let px = w;
let py = h;
const pointedArea = {
points: [],
boundingBox: [px, py, px, py],
childHoles: [],
isHole: (edge === 11)
};
paths[pathIx] = pointedArea;
let areaClosed = false;
let direction = 1;
// Path points loop
while (!areaClosed) {
const edgeType = edgeRaster[py][px];
this.addPointToArea(pointedArea, px - 1, py - 1, edgeType);
// Next: look up the replacement, direction and coordinate changes = clear this cell, turn if required, walk forward
const lookupRow = PATH_SCAN_COMBINED_LOOKUP[edgeType][direction];
edgeRaster[py][px] = lookupRow[0];
direction = lookupRow[1];
px += lookupRow[2];
py += lookupRow[3];
// Close path
if (px - 1 === pointedArea.points[0].x &&
py - 1 === pointedArea.points[0].y) {
areaClosed = true;
// Discarding paths shorter than pathMinLength
if (pointedArea.points.length < pathMinLength) {
paths.pop();
continue;
}
if (pointedArea.isHole) {
// Finding the parent shape for this hole
const parentId = this.findParentId(pointedArea, paths, pathIx, width, height);
paths[parentId].childHoles.push(pathIx);
}
pathIx++;
}
}
}
}
return paths;
}
addPointToArea(area, x, y, edgeType) {
const point = { x, y, data: edgeType };
// Bounding box
if (x < area.boundingBox[0]) {
area.boundingBox[0] = x;
}
if (y < area.boundingBox[1]) {
area.boundingBox[1] = y;
}
if (x > area.boundingBox[2]) {
area.boundingBox[2] = x;
}
if (y > area.boundingBox[3]) {
area.boundingBox[3] = y;
}
return area.points.push(point);
}
findParentId(path, paths, maxPath, w, h) {
let parentId = 0;
let parentbbox = [-1, -1, w + 1, h + 1];
for (let parentIx = 0; parentIx < maxPath; parentIx++) {
const parentPath = paths[parentIx];
if (!parentPath.isHole
&& this.boundingBoxIncludes(parentPath.boundingBox, path.boundingBox)
&& this.boundingBoxIncludes(parentbbox, parentPath.boundingBox)
&& this.pointInPolygon(path.points[0], parentPath.points)) {
parentId = parentIx;
parentbbox = parentPath.boundingBox;
}
}
return parentId;
}
boundingBoxIncludes(parentbbox, childbbox) {
return ((parentbbox[0] < childbbox[0]) &&
(parentbbox[1] < childbbox[1]) &&
(parentbbox[2] > childbbox[2]) &&
(parentbbox[3] > childbbox[3]));
}
// Point in polygon test
pointInPolygon(point, path) {
let isIn = false;
for (let i = 0, j = path.length - 1; i < path.length; j = i++) {
isIn = (((path[i].y > point.y) !== (path[j].y > point.y)) &&
(point.x < (path[j].x - path[i].x) * (point.y - path[i].y) / (path[j].y - path[i].y) + path[i].x))
? !isIn : isIn;
}
return isIn;
}
}
var SvgLineAttributes;
(function (SvgLineAttributes) {
function toString(la) {
const str = `${la.type} ${la.x1} ${la.y1} ${la.x2} ${la.y2}`;
if (la.type === 'L') {
return str;
}
return str + ` ${la.x3} ${la.y3}`;
}
SvgLineAttributes.toString = toString;
})(SvgLineAttributes || (SvgLineAttributes = {}));
class PathTracer {
// 5. tracepath() : recursively trying to fit straight and quadratic spline segments on the 8 direction internode path
// 5.1. Find sequences of points with only 2 segment types
// 5.2. Fit a straight line on the sequence
// 5.3. If the straight line fails (distance error > lineErrorMargin), find the point with the biggest error
// 5.4. Fit a quadratic spline through errorpoint (project this to get controlpoint), then measure errors on every point in the sequence
// 5.5. If the spline fails (distance error > curveErrorMargin), find the point with the biggest error, set splitpoint = fitting point
// 5.6. Split sequence and recursively apply 5.2. - 5.6. to startpoint-splitpoint and splitpoint-endpoint sequences
trace(path, lineErrorMargin, curveErrorMargin) {
const pathCommands = [];
const points = [].concat(path.points);
points.push(points[0]); // we want to end on the point we started on
for (let sequenceStartIx = 0; sequenceStartIx < points.length - 1;) {
const nextSequenceStartIx = this.findNextSequenceStartIx(points, sequenceStartIx);
const commandSequence = this.getPathCommandsOfSequence(points, lineErrorMargin, curveErrorMargin, sequenceStartIx, nextSequenceStartIx);
pathCommands.push(...commandSequence);
sequenceStartIx = nextSequenceStartIx;
}
return {
lineAttributes: pathCommands,
boundingBox: path.boundingBox,
childHoles: path.childHoles,
isHole: path.isHole
};
}
/**
* Find sequence of points with 2 trajectories.
*
* @param points
* @param startIx
* @returns The index where the next sequence starts
*/
findNextSequenceStartIx(points, startIx) {
const startTrajectory = points[startIx].data;
let nextIx = startIx + 1;
let nextPoint = points[nextIx];
let secondTrajectory = null;
while ((nextPoint.data === startTrajectory ||
nextPoint.data === secondTrajectory ||
secondTrajectory === null) && nextIx < points.length - 1) {
if (nextPoint.data !== startTrajectory &&
secondTrajectory === null) {
secondTrajectory = nextPoint.data;
}
nextIx++;
nextPoint = points[nextIx];
}
// the very last point is same as start point and part of the last sequence, no matter its type
return (nextIx === points.length - 2) ? nextIx + 1 : nextIx;
}
// 5.2. - 5.6. recursively fitting a straight or quadratic line segment on this sequence of path nodes,
// called from tracepath()
getPathCommandsOfSequence(points, lineErrorMargin, curveErrorMargin, sequenceStartIx, sequenceEndIx) {
if (sequenceEndIx > points.length || sequenceEndIx < 0) {
return [];
}
const isLineResult = this.checkSequenceFitsLine(points, lineErrorMargin, sequenceStartIx, sequenceEndIx);
if (typeof isLineResult === 'object') {
return [isLineResult];
}
const isCurveResult = this.checkSequenceFitsCurve(points, curveErrorMargin, sequenceStartIx, sequenceEndIx, isLineResult);
if (typeof isCurveResult === 'object') {
return [isCurveResult];
}
// 5.5. If the spline fails (distance error>curveErrorMargin), find the point with the biggest error
const splitPoint = isLineResult;
const seqSplit1 = this.getPathCommandsOfSequence(points, lineErrorMargin, curveErrorMargin, sequenceStartIx, splitPoint);
const seqSplit2 = this.getPathCommandsOfSequence(points, lineErrorMargin, curveErrorMargin, splitPoint, sequenceEndIx);
return seqSplit1.concat(seqSplit2);
}
checkSequenceFitsLine(points, lineErrorMargin, sequenceStartIx, sequenceEndIx) {
const sequenceLength = sequenceEndIx - sequenceStartIx;
const startPoint = points[sequenceStartIx];
const endPoint = points[sequenceEndIx];
const gainX = (endPoint.x - startPoint.x) / sequenceLength;
const gainY = (endPoint.y - startPoint.y) / sequenceLength;
// 5.2. Fit a straight line on the sequence
let isStraightLine = true;
let maxDiffIx = sequenceStartIx;
let maxDiff = 0;
for (let pointIx = sequenceStartIx + 1; pointIx < sequenceEndIx; pointIx++) {
const subsequenceLength = pointIx - sequenceStartIx;
const expectedX = startPoint.x + subsequenceLength * gainX;
const expectedY = startPoint.y + subsequenceLength * gainY;
const point = points[pointIx];
const diff = (point.x - expectedX) * (point.x - expectedX) + (point.y - expectedY) * (point.y - expectedY);
if (diff > lineErrorMargin) {
isStraightLine = false;
}
if (diff > maxDiff) {
maxDiffIx = pointIx;
maxDiff = diff;
}
}
if (!isStraightLine) {
return maxDiffIx;
}
return {
type: 'L',
x1: startPoint.x,
y1: startPoint.y,
x2: endPoint.x,
y2: endPoint.y
};
}
checkSequenceFitsCurve(points, curveErrorMargin, sequenceStartIx, sequenceEndIx, turningPointIx) {
const sequenceLength = sequenceEndIx - sequenceStartIx;
const startPoint = points[sequenceStartIx];
const endPoint = points[sequenceEndIx];
let isCurve = true;
let maxDiff = 0;
let maxDiffIx = sequenceStartIx;
// 5.4. Fit a quadratic spline through this point, measure errors on every point in the sequence
// helpers and projecting to get control point
let subsequenceLength = turningPointIx - sequenceStartIx, t = subsequenceLength / sequenceLength, t1 = (1 - t) * (1 - t), t2 = 2 * (1 - t) * t, t3 = t * t;
const qControlPointX = (t1 * startPoint.x + t3 * endPoint.x - points[turningPointIx].x) / -t2;
const qControlPointY = (t1 * startPoint.y + t3 * endPoint.y - points[turningPointIx].y) / -t2;
// Check every point
for (let pointIx = sequenceStartIx + 1; pointIx != sequenceEndIx; pointIx = (pointIx + 1) % points.length) {
subsequenceLength = pointIx - sequenceStartIx;
t = subsequenceLength / sequenceLength;
t1 = (1 - t) * (1 - t);
t2 = 2 * (1 - t) * t;
t3 = t * t;
const px = t1 * startPoint.x + t2 * qControlPointX + t3 * endPoint.x;
const py = t1 * startPoint.y + t2 * qControlPointY + t3 * endPoint.y;
const point = points[pointIx];
const diff = (point.x - px) * (point.x - px) + (point.y - py) * (point.y - py);
if (diff > curveErrorMargin) {
isCurve = false;
}
if (diff > maxDiff) {
maxDiffIx = pointIx;
maxDiff = diff;
}
}
if (!isCurve) {
return maxDiffIx;
}
return {
type: 'Q',
x1: startPoint.x,
y1: startPoint.y,
x2: qControlPointX,
y2: qControlPointY,
x3: endPoint.x,
y3: endPoint.y
};
}
}
var TraceDataTrimmer;
(function (TraceDataTrimmer) {
function trim(traceData, strokeWidth, keepAspectRatio, verbose = false) {
const offsets = getOffsets(traceData, strokeWidth);
if (keepAspectRatio) {
applyAspectRatio(offsets, traceData);
}
if (offsets.minX === 0 &&
offsets.maxX === traceData.width &&
offsets.minY === 0 &&
offsets.maxY === traceData.height) {
return;
}
verbose && console.log(`Trimming x[${offsets.minX}|${offsets.maxX}]/${traceData.width}, y[${offsets.minY}|${offsets.maxY}]/${traceData.height}`);
updateData(traceData, offsets);
}
TraceDataTrimmer.trim = trim;
function getOffsets(traceData, strokeWidth) {
let minX = traceData.width, minY = traceData.height, maxX = 0, maxY = 0;
for (let colorId = 0; colorId < traceData.areasByColor.length; colorId++) {
const color = traceData.colors[colorId];
if (color.a === 0) {
continue;
}
const colorArea = traceData.areasByColor[colorId];
for (const area of colorArea) {
for (const line of area.lineAttributes) {
const isLineQ = line.type === "Q";
minX = Math.min(minX, line.x1, line.x2, isLineQ ? line.x3 : minX);
maxX = Math.max(maxX, line.x1, line.x2, isLineQ ? line.x3 : 0);
minY = Math.min(minY, line.y1, line.y2, isLineQ ? line.y3 : minY);
maxY = Math.max(maxY, line.y1, line.y2, isLineQ ? line.y3 : 0);
}
}
}
const strokeBorder = Math.floor(strokeWidth / 2);
minX -= strokeBorder;
minY -= strokeBorder;
maxX += strokeBorder;
maxY += strokeBorder;
return { minX, maxX, minY, maxY };
}
function applyAspectRatio(offsets, traceData) {
const trimmedWidth = offsets.maxX - offsets.minX;
const trimmedHeight = offsets.maxY - offsets.minY;
const oldWidth = traceData.width;
const oldHeight = traceData.height;
const expectedTrimmedWidth = Math.ceil(trimmedHeight * oldWidth / oldHeight);
if (trimmedWidth === expectedTrimmedWidth) {
return;
}
if (expectedTrimmedWidth > trimmedWidth) {
const diff = (expectedTrimmedWidth - trimmedWidth) / 2;
offsets.minX -= Math.ceil(diff);
offsets.maxX += Math.floor(diff);
return;
}
const expectedTrimmedHeight = Math.ceil(trimmedWidth * oldHeight / oldWidth);
const diff = (expectedTrimmedHeight - trimmedHeight) / 2;
offsets.minY -= Math.ceil(diff);
offsets.maxY += Math.floor(diff);
}
function updateData(traceData, offsets) {
const { minX, maxX, minY, maxY } = offsets;
for (const colorArea of traceData.areasByColor) {
for (const area of colorArea) {
for (const lineAttribute of area.lineAttributes) {
lineAttribute.x1 -= minX;
lineAttribute.x2 -= minX;
lineAttribute.y1 -= minY;
lineAttribute.y2 -= minY;
if (lineAttribute.type === 'Q') {
lineAttribute.x3 -= minX;
lineAttribute.y3 -= minY;
}
}
}
}
traceData.height = maxY - minY;
traceData.width = maxX - minX;
}
})(TraceDataTrimmer || (TraceDataTrimmer = {}));
class SvgDrawer {
options;
useStroke;
useFill;
constructor(options) {
this.options = Object.assign({}, SvgDrawerDefaultOptions, options);
this.useFill = [FillStyle.FILL, FillStyle.STROKE_FILL].includes(this.options.fillStyle);
this.useStroke = [FillStyle.STROKE, FillStyle.STROKE_FILL].includes(this.options.fillStyle);
}
fixValue(val) {
if (this.options.scale !== 1) {
val *= this.options.scale;
}
if (this.options.decimalPlaces === -1) {
return val;
}
return +val.toFixed(this.options.decimalPlaces);
}
draw(traceData) {
this.init(traceData);
const tags = [];
for (let colorId = 0; colorId < traceData.areasByColor.length; colorId++) {
for (let areaIx = 0; areaIx < traceData.areasByColor[colorId].length; areaIx++) {
if (traceData.areasByColor[colorId][areaIx].isHole) {
continue;
}
tags.push(...this.buildSegmentTags(traceData, colorId, areaIx));
}
}
if (this.options.verbose) {
console.log(`Adding ${tags.length} <path> tags to SVG.`);
}
return this.buildSvgTag(traceData, tags);
}
init(traceData) {
if (this.options.trim !== TrimMode.OFF) {
const strokeWidth = (this.options.fillStyle === FillStyle.FILL) ? 0 : this.options.strokeWidth;
const keepAspectRatio = this.options.trim === TrimMode.KEEP_RATIO;
TraceDataTrimmer.trim(traceData, strokeWidth, keepAspectRatio, this.options.verbose);
}
}
/**
* Builds a <path> tag for each segment.
*
* @param traceData
* @param colorId
* @param segmentIx
* @returns
*/
buildSegmentTags(traceData, colorId, segmentIx) {
const colorSegments = traceData.areasByColor[colorId];
const area = colorSegments[segmentIx];
const color = traceData.colors[colorId];
if (!this.isValidLine(color, area.lineAttributes)) {
return [];
}
const tags = [];
const desc = (this.options.desc ? this.getDescriptionAttribute(traceData, colorId, segmentIx) : '');
const tag = this.buildPathTag(area, colorSegments, color, desc);
tags.push(tag);
// Rendering control points
if (this.options.segmentEndpointRadius || this.options.curveControlPointRadius) {
const controlPoints = this.drawControlOutput(area, colorSegments);
tags.push(...controlPoints);
}
return tags;
}
isValidLine(color, lineAttributes) {
const passesLineFilter = !this.options.lineFilter || lineAttributes.length >= 3;
return !color.isInvisible() && passesLineFilter;
}
getDescriptionAttribute(traceData, colorId, segmentIx) {
const area = traceData.areasByColor[colorId][segmentIx];
const isHole = area.isHole ? 1 : 0;
const color = traceData.colors[colorId];
const colorStr = `r:${color.r} g:${color.g} b:${color.b}`;
return (this.options.desc ? (`desc="colorId:${colorId} segment:${segmentIx} ${colorStr} isHole:${isHole}" `) : '');
}
buildPath(segment, colorSegments) {
const lines = segment.lineAttributes;
const pathStr = [];
// Creating non-hole path string
pathStr.push('M', this.fixValue(lines[0].x1), this.fixValue(lines[0].y1));
for (const line of lines) {
pathStr.push(line.type, this.fixValue(line.x2), this.fixValue(line.y2));
if ('x3' in line) {
pathStr.push(this.fixValue(line.x3), this.fixValue(line.y3));
}
}
pathStr.push('Z');
// Hole children
for (const holeIx of segment.childHoles) {
const holeSegments = colorSegments[holeIx];
const lastLine = holeSegments.lineAttributes[holeSegments.lineAttributes.length - 1];
pathStr.push('M');
// Creating hole path string
if (lastLine.type === 'Q') {
pathStr.push(this.fixValue(lastLine.x3), this.fixValue(lastLine.y3));
}
else {
pathStr.push(this.fixValue(lastLine.x2), this.fixValue(lastLine.y2));
}
for (const holeLine of holeSegments.lineAttributes.reverse()) {
pathStr.push(holeLine.type);
if (