'use strict'; var Potrace = require('./Potrace'); var utils = require('./utils'); /** * Takes multiple samples using {@link Potrace} with different threshold * settings and combines output into a single file. * * @param {Posterizer~Options} [options] * @constructor */ function Posterizer(options) { this._potrace = new Potrace(); this._calculatedThreshold = null; this._params = { threshold: Potrace.THRESHOLD_AUTO, blackOnWhite: true, steps: Posterizer.STEPS_AUTO, background: Potrace.COLOR_TRANSPARENT, fillStrategy: Posterizer.FILL_DOMINANT, rangeDistribution: Posterizer.RANGES_AUTO }; if (options) { this.setParameters(options); } } // Inherit constants from Potrace class for (var key in Potrace) { if (Object.prototype.hasOwnProperty.call(Potrace, key) && key === key.toUpperCase()) { Posterizer[key] = Potrace[key]; } } Posterizer.STEPS_AUTO = -1; Posterizer.FILL_SPREAD = 'spread'; Posterizer.FILL_DOMINANT = 'dominant'; Posterizer.FILL_MEDIAN = 'median'; Posterizer.FILL_MEAN = 'mean'; Posterizer.RANGES_AUTO = 'auto'; Posterizer.RANGES_EQUAL = 'equal'; Posterizer.prototype = { /** * Fine tuning to color ranges. * * If last range (featuring most saturated color) is larger than 10% of color space (25 units) * then we want to add another color stop, that hopefully will include darkest pixels, improving presence of * shadows and line art * * @param ranges * @private */ _addExtraColorStop: function(ranges) { var blackOnWhite = this._params.blackOnWhite; var lastColorStop = ranges[ranges.length - 1]; var lastRangeFrom = blackOnWhite ? 0 : lastColorStop.value; var lastRangeTo = blackOnWhite ? lastColorStop.value : 255; if (lastRangeTo - lastRangeFrom > 25 && lastColorStop.colorIntensity !== 1) { var histogram = this._getImageHistogram(); var levels = histogram.getStats(lastRangeFrom, lastRangeTo).levels; var newColorStop = levels.mean + levels.stdDev <= 25 ? levels.mean + levels.stdDev : levels.mean - levels.stdDev <= 25 ? levels.mean - levels.stdDev : 25; var newStats = (blackOnWhite ? histogram.getStats(0, newColorStop) : histogram.getStats(newColorStop, 255)); var color = newStats.levels.mean; ranges.push({ value: Math.abs((blackOnWhite ? 0 : 255) - newColorStop), colorIntensity: isNaN(color) ? 0 : ((blackOnWhite ? 255 - color : color) / 255) }); } return ranges; }, /** * Calculates color intensity for each element of numeric array * * @param {number[]} colorStops * @returns {{ levels: number, colorIntensity: number }[]} * @private */ _calcColorIntensity: function(colorStops) { var blackOnWhite = this._params.blackOnWhite; var colorSelectionStrat = this._params.fillStrategy; var histogram = colorSelectionStrat !== Posterizer.FILL_SPREAD ? this._getImageHistogram() : null; var fullRange = Math.abs(this._paramThreshold() - (blackOnWhite ? 0 : 255)); return colorStops.map(function(threshold, index) { var nextValue = index + 1 === colorStops.length ? (blackOnWhite ? -1 : 256) : colorStops[index + 1]; var rangeStart = Math.round(blackOnWhite ? nextValue + 1 : threshold); var rangeEnd = Math.round(blackOnWhite ? threshold : nextValue - 1); var factor = index / (colorStops.length - 1); var intervalSize = rangeEnd - rangeStart; var stats = histogram.getStats(rangeStart, rangeEnd); var color = -1; if (stats.pixels === 0) { return { value: threshold, colorIntensity: 0 }; } switch (colorSelectionStrat) { case Posterizer.FILL_SPREAD: // We want it to be 0 (255 when white on black) at the most saturated end, so... color = (blackOnWhite ? rangeStart : rangeEnd) + (blackOnWhite ? 1 : -1) * intervalSize * Math.max(0.5, fullRange / 255) * factor; break; case Posterizer.FILL_DOMINANT: color = histogram.getDominantColor(rangeStart, rangeEnd, utils.clamp(intervalSize, 1, 5)); break; case Posterizer.FILL_MEAN: color = stats.levels.mean; break; case Posterizer.FILL_MEDIAN: color = stats.levels.median; break; } // We don't want colors to be too close to each other, so we introduce some spacing in between if (index !== 0) { color = blackOnWhite ? utils.clamp(color, rangeStart, rangeEnd - Math.round(intervalSize * 0.1)) : utils.clamp(color, rangeStart + Math.round(intervalSize * 0.1), rangeEnd); } return { value: threshold, colorIntensity: color === -1 ? 0 : ((blackOnWhite ? 255 - color : color) / 255) }; }); }, /** * @returns {Histogram} * @private */ _getImageHistogram: function() { return this._potrace._luminanceData.histogram(); }, /** * Processes threshold, steps and rangeDistribution parameters and returns normalized array of color stops * @returns {*} * @private */ _getRanges: function() { var steps = this._paramSteps(); if (!Array.isArray(steps)) { return this._params.rangeDistribution === Posterizer.RANGES_AUTO ? this._getRangesAuto() : this._getRangesEquallyDistributed(); } // Steps is array of thresholds and we want to preprocess it var colorStops = []; var threshold = this._paramThreshold(); var lookingForDarkPixels = this._params.blackOnWhite; steps.forEach(function(item) { if (colorStops.indexOf(item) === -1 && utils.between(item, 0, 255)) { colorStops.push(item); } }); if (!colorStops.length) { colorStops.push(threshold); } colorStops = colorStops.sort(function (a, b) { return a < b === lookingForDarkPixels ? 1 : -1; }); if (lookingForDarkPixels && colorStops[0] < threshold) { colorStops.unshift(threshold); } else if (!lookingForDarkPixels && colorStops[colorStops.length - 1] < threshold) { colorStops.push(threshold); } return this._calcColorIntensity(colorStops); }, /** * Calculates given (or lower) number of thresholds using automatic thresholding algorithm * @returns {*} * @private */ _getRangesAuto: function() { var histogram = this._getImageHistogram(); var steps = this._paramSteps(true); var colorStops; if (this._params.threshold === Potrace.THRESHOLD_AUTO) { colorStops = histogram.multilevelThresholding(steps); } else { var threshold = this._paramThreshold(); colorStops = this._params.blackOnWhite ? histogram.multilevelThresholding(steps - 1, 0, threshold) : histogram.multilevelThresholding(steps - 1, threshold, 255); if (this._params.blackOnWhite) { colorStops.push(threshold); } else { colorStops.unshift(threshold); } } if (this._params.blackOnWhite) { colorStops = colorStops.reverse(); } return this._calcColorIntensity(colorStops); }, /** * Calculates color stops and color representing each segment, returning them * from least to most intense color (black or white, depending on blackOnWhite parameter) * * @private */ _getRangesEquallyDistributed: function() { var blackOnWhite = this._params.blackOnWhite; var colorsToThreshold = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold(); var steps = this._paramSteps(); var stepSize = colorsToThreshold / steps; var colorStops = []; var i = steps - 1, factor, threshold; while (i >= 0) { factor = i / (steps - 1); threshold = Math.min(colorsToThreshold, (i + 1) * stepSize); threshold = blackOnWhite ? threshold : 255 - threshold; i--; colorStops.push(threshold); } return this._calcColorIntensity(colorStops); }, /** * Returns valid steps value * @param {Boolean} [count=false] * @returns {number|number[]} * @private */ _paramSteps: function(count) { var steps = this._params.steps; if (Array.isArray(steps)) { return count ? steps.length : steps; } if (steps === Posterizer.STEPS_AUTO && this._params.threshold === Potrace.THRESHOLD_AUTO) { return 4; } var blackOnWhite = this._params.blackOnWhite; var colorsCount = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold(); return steps === Posterizer.STEPS_AUTO ? (colorsCount > 200 ? 4 : 3) : Math.min(colorsCount, Math.max(2, steps)); }, /** * Returns valid threshold value * @returns {number} * @private */ _paramThreshold: function() { if (this._calculatedThreshold !== null) { return this._calculatedThreshold; } if (this._params.threshold !== Potrace.THRESHOLD_AUTO) { this._calculatedThreshold = this._params.threshold; return this._calculatedThreshold; } var twoThresholds = this._getImageHistogram().multilevelThresholding(2); this._calculatedThreshold = this._params.blackOnWhite ? twoThresholds[1] : twoThresholds[0]; this._calculatedThreshold = this._calculatedThreshold || 128; return this._calculatedThreshold; }, /** * Running potrace on the image multiple times with different thresholds and returns an array * of path tags * * @param {Boolean} [noFillColor] * @returns {string[]} * @private */ _pathTags: function(noFillColor) { var ranges = this._getRanges(); var potrace = this._potrace; var blackOnWhite = this._params.blackOnWhite; if (ranges.length >= 10) { ranges = this._addExtraColorStop(ranges); } potrace.setParameters({ blackOnWhite: blackOnWhite }); var actualPrevLayersOpacity = 0; return ranges.map(function(colorStop) { var thisLayerOpacity = colorStop.colorIntensity; if (thisLayerOpacity === 0) { return ''; } // NOTE: With big number of layers (something like 70) there will be noticeable math error on rendering side. // In Chromium at least image will end up looking brighter overall compared to the same layers painted in solid colors. // However it works fine with sane number of layers, and it's not like we can do much about it. var calculatedOpacity = (!actualPrevLayersOpacity || thisLayerOpacity === 1) ? thisLayerOpacity : ((actualPrevLayersOpacity - thisLayerOpacity) / (actualPrevLayersOpacity - 1)); calculatedOpacity = utils.clamp(parseFloat(calculatedOpacity.toFixed(3)), 0, 1); actualPrevLayersOpacity = actualPrevLayersOpacity + (1 - actualPrevLayersOpacity) * calculatedOpacity; potrace.setParameters({ threshold: colorStop.value }); var element = noFillColor ? potrace.getPathTag('') : potrace.getPathTag(); element = utils.setHtmlAttr(element, 'fill-opacity', calculatedOpacity.toFixed(3)); var canBeIgnored = calculatedOpacity === 0 || element.indexOf(' d=""') !== -1; // var c = Math.round(Math.abs((blackOnWhite ? 255 : 0) - 255 * thisLayerOpacity)); // element = utils.setHtmlAttr(element, 'fill', 'rgb('+c+', '+c+', '+c+')'); // element = utils.setHtmlAttr(element, 'fill-opacity', ''); return canBeIgnored ? '' : element; }); }, /** * Loads image. * * @param {string|Buffer|Jimp} target Image source. Could be anything that {@link Jimp} can read (buffer, local path or url). Supported formats are: PNG, JPEG or BMP * @param {Function} callback */ loadImage: function(target, callback) { var self = this; this._potrace.loadImage(target, function(err) { self._calculatedThreshold = null; callback.call(self, err); }); }, /** * Sets parameters. Accepts same object as {Potrace} * * @param {Posterizer~Options} params */ setParameters: function(params) { if (!params) { return; } this._potrace.setParameters(params); if (params.steps && !Array.isArray(params.steps) && (!utils.isNumber(params.steps) || !utils.between(params.steps, 1, 255))) { throw new Error('Bad \'steps\' value'); } for (var key in this._params) { if (this._params.hasOwnProperty(key) && params.hasOwnProperty(key)) { this._params[key] = params[key]; } } this._calculatedThreshold = null; }, /** * Returns image as tag. Always has viewBox specified * * @param {string} id */ getSymbol: function(id) { var width = this._potrace._luminanceData.width; var height = this._potrace._luminanceData.height; var paths = this._pathTags(true); return '' + paths.join('') + ''; }, /** * Generates SVG image * @returns {String} */ getSVG: function() { var width = this._potrace._luminanceData.width, height = this._potrace._luminanceData.height; var tags = this._pathTags(false); var svg = '\n\t' + (this._params.background !== Potrace.COLOR_TRANSPARENT ? '\n\t' : '') + tags.join('\n\t') + '\n'; return svg.replace(/\n(?:\t*\n)+(\t*)/g, '\n$1'); } }; module.exports = Posterizer; /** * Posterizer options * * @typedef {Potrace~Options} Posterizer~Options * @property {Number} [steps] - Number of samples that needs to be taken (and number of layers in SVG). (default: Posterizer.STEPS_AUTO, which most likely will result in 3, sometimes 4) * @property {*} [fillStrategy] - How to select fill color for color ranges - equally spread or dominant. (default: Posterizer.FILL_DOMINANT) * @property {*} [rangeDistribution] - How to choose thresholds in-between - after equal intervals or automatically balanced. (default: Posterizer.RANGES_AUTO) */