UNPKG

@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
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 (