Mini Kabibi Habibi
/*
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
(function () {
"use strict";
var spawn = require("child_process").spawn,
Q = require("q");
// Constants
var DEFAULT_IMAGE_QUALITY = "90",
BACKGROUND_COLOR_FOR_FLATTENING = "#fff";
function _shouldUseFlite(settings, binaryPaths) {
// For now, don't use FLITE for GIF (the palette building code works but could be improved)
// nor for PDF which is used by DesignSpace and only supported by IM. May not be supported long term.
// currently Webp is also not supported by FLITE
return settings.useFlite && binaryPaths.flite && settings.format !== "gif" && settings.format !== "pdf" &&
settings.format !== "webp";
}
function _getConvertArgumentsForSettings(settings, binaryPaths) {
var args = [],
format = settings.format,
intQuality = Number.parseInt(settings.quality, 10),
quality = Number.isInteger(intQuality) ? String(intQuality) : DEFAULT_IMAGE_QUALITY,
lossless = settings.lossless,
_scale = settings._scale ? parseFloat(settings._scale) : 1.0;
// Note: The _scale setting should be considered private and may be removed at any time
// with only a bump to the "patch" version number of generator-core. Use at your own risk.
if (_scale && _scale !== 1.0) {
args.push("-resize", Math.round(_scale * 100) + "%");
}
// Now perform color conversions
if (format === "jpg" || (format === "png" && quality === "24")) {
// Blend against a white background. Otherwise, semi-transparent pixels would just
// lose their transparency, making the colors too intense
args.push("-background", BACKGROUND_COLOR_FOR_FLATTENING, "-flatten");
}
if (format === "gif" && !_shouldUseFlite(settings, binaryPaths)) {
// Make it so that pixels that were <1% transparent before become fully transparent
// while the other pixels have the same color as if seen against a white background
// Create a copy of the original image, making it truly RGBA, and delete the ARGB original
// Copy the image and flatten it, then apply the binary transparency of another copy
// Afterwards, remove the RGBA image as well, leaving just one image
args = args.concat(("( -clone 0 ) -delete 0 ( -clone 0 -background " + BACKGROUND_COLOR_FOR_FLATTENING +
" -flatten -clone 0 -channel A -threshold 99% -compose dst-in -composite ) -delete 0").split(/ /));
}
if (format === "png") {
// Avoid embedding time stamps into the file.
args.push("-define", "png:exclude-chunk=date");
if (binaryPaths.pngquant && settings.usePngquant && quality === "8") {
// Use ImageMagick to create 32-bit PNG with appropriate padding, etc.
// and then pngquant will convert it to PNG8.
quality = "32";
settings.quality = 32;
settings.pngquantQuality = 8;
}
if (quality === "8") {
// The default Riemersma dithering is broken in ImageMagick 6.8.6-2
// Use Floyd Steinberg instead - it looks better, too
args.push("-dither", "FloydSteinberg");
// Do not force type 3 as that seems to remove the alpha channel
// Just reduce the number of colors, inspiring ImageMagick to use
// a palette anyway (with RGBA colors)
args.push("-colors", 256);
} else if (quality === "24") {
args.push("-define", "PNG:color-type=" + 2);
} else if (quality === "32") {
// This only forces RGBA colors when quality 32 is set explicitly.
// Otherwise, ImageMagick gets to decide.
args.push("-define", "PNG:color-type=" + 6);
}
}
if (format === "jpg" || format === "webp") {
args.push("-quality", quality);
}
if (format === "webp") {
if (lossless !== undefined) {
args.push("-define", "webp:lossless=" + Boolean(lossless));
}
}
return args;
}
function _getConvertArguments(binaryPaths, pixmap, settings, logger) {
var finalWidth = pixmap.width,
finalHeight = pixmap.height;
// Define the input
var args = [
// In order to know the pixel boundaries, ImageMagick needs to know the resolution and pixel depth
"-size", pixmap.width + "x" + pixmap.height,
"-depth", pixmap.bitsPerChannel,
// pixmap.pixels contains the pixels in ARGB format, but ImageMagick only understands RGBA
// The color-matrix parameter allows us to compensate for that
"-color-matrix", "0 1 0 0, 0 0 1 0, 0 0 0 1, 1 0 0 0"
];
if (!isNaN(settings.ppi)) {
// Pass information about the image's pixel density
args.push("-units", "PixelsPerInch", "-density", settings.ppi);
} else {
logger.warn("Did not pass the document's resolution because it is not a valid number:", settings.ppi);
}
if (settings.extract && Number.isFinite(settings.extract.width) && settings.extract.width > 0 &&
Number.isFinite(settings.extract.height) && settings.extract.height > 0) {
finalWidth = settings.extract.width;
finalHeight = settings.extract.height;
var x = Number.isFinite(settings.extract.x) ? settings.extract.x : 0,
y = Number.isFinite(settings.extract.y) ? settings.extract.y : 0,
xSign = x < 0 ? "" : "+",
ySign = y < 0 ? "" : "+";
//extract offsets explictly need a sign. Negagtive x/y will include a "-" sign in toString
//and positive values will get an explicit "+" before them
args.push("-extract", finalWidth + "x" + finalHeight + xSign + x + ySign + y);
}
// Read the pixels in RGBA form from STDIN
args.push("rgba:-");
var padding = settings.padding;
if (padding) {
// Calculate the new image size
finalWidth = finalWidth + padding.left + padding.right;
finalHeight = finalHeight + padding.top + padding.bottom;
// Apply background color or use transparent ("transparent" doesn't work because Colors.xml is missing)
var background = settings.background || [0, 0, 0, 0];
background = "rgba(" + background.join(",") + ")";
args.push("-background", background);
// Set the canvas size and position the image inside of it
args.push("-extent", finalWidth + "x" + finalHeight + "-" + padding.left + "-" + padding.top);
}
var useFlite = _shouldUseFlite(settings, binaryPaths);
if (pixmap.iccProfile && useFlite) {
args.push("--profile-data-len", pixmap.iccProfile.length);
}
// Define conversions
args = args.concat(_getConvertArgumentsForSettings(settings, binaryPaths));
// Define the output
var format = settings.format;
// "png8" as the ImageMagick format produces GIF-like PNGs (binary transparency)
if (format === "png" && settings.quality && settings.quality !== 8) {
format = format + settings.quality;
}
// Write an image of format <format> to STDOUT
if (format === "jpg" && useFlite && settings.useJPGEncoding === "optimal") {
args.push(format + ":encode-optimal");
} else if (format === "jpg" && useFlite && settings.useJPGEncoding === "precomputed") {
args.push(format + ":encode-precomputed");
} else {
args.push(format + ":-");
}
return args;
}
function _rejectDeferredOnProcessIOError(process, processName, deferred) {
process.on("error", function (err) {
deferred.reject("Error with " + processName + ": " + err);
});
process.stdin.on("error", function (err) {
deferred.reject("Error with " + processName + "'s STDIN: " + err);
});
process.stdout.on("error", function (err) {
deferred.reject("Error with " + processName + "'s STDOUT: " + err);
});
process.stderr.on("error", function (err) {
deferred.reject("Error with " + processName + "'s STDERR: " + err);
});
}
function _shouldUsePNGQuant(settings) {
return settings.usePngquant && settings.format === "png" && settings.pngquantQuality === 8;
}
function _pipeThroughPNGQuant(binaryPaths, inputStream, outputStream, outputCompleteDeferred) {
var pngquantProc = spawn(binaryPaths.pngquant, ["-"]);
_rejectDeferredOnProcessIOError(pngquantProc, "pngquant", outputCompleteDeferred);
pngquantProc.stdout.pipe(outputStream);
inputStream.pipe(pngquantProc.stdin);
}
// Exported functions
function streamPixmap(binaryPaths, pixmap, outputStream, settings, logger) {
var outputCompleteDeferred = Q.defer();
var args = _getConvertArguments(binaryPaths, pixmap, settings, logger);
// Launch convert
var convertProc = (_shouldUseFlite(settings, binaryPaths) ?
spawn(binaryPaths.flite, args) :
spawn(binaryPaths.convert, args));
if (_shouldUseFlite(settings, binaryPaths)) {
logger.log("Using Flite for image encoding" + (pixmap.iccProfile ? " with icc profile" : ""));
}
// Handle errors
_rejectDeferredOnProcessIOError(convertProc, "convert", outputCompleteDeferred);
// Capture STDERR
var stderr = "";
convertProc.stderr.on("data", function (chunk) {
stderr += chunk;
});
// pngquant changes the process from `convert < pixmap > outputStream`
// to `convert < pixmap | pngquant > outputStream`
if (_shouldUsePNGQuant(settings)) {
_pipeThroughPNGQuant(binaryPaths, convertProc.stdout, outputStream, outputCompleteDeferred);
} else {
// Pipe convert's output (the produced image) into the output stream
convertProc.stdout.pipe(outputStream);
}
//send the iccProfile if present
if (pixmap.iccProfile && _shouldUseFlite(settings, binaryPaths)) {
convertProc.stdin.write(pixmap.iccProfile);
}
// Send the pixmap to convert
convertProc.stdin.end(pixmap.pixels);
// Wait until convert is done (pipe from the last utility will close the stream)
outputStream.on("close", function () {
if (stderr) {
outputCompleteDeferred.reject("ImageMagick error: " + stderr);
} else {
outputCompleteDeferred.resolve();
}
});
return outputCompleteDeferred.promise;
}
function savePixmap(binaryPaths, pixmap, path, settings, logger) {
var fs = require("fs");
// Open a stream to the output file.
var fileStream = fs.createWriteStream(path);
// Stream the pixmap into the file and resolve with path when successful.
return streamPixmap(binaryPaths, pixmap, fileStream, settings, logger)
.thenResolve(path)
.catch(function (err) {
// If an error occurred, clean up the file.
try {
fileStream.close();
} catch (e) {
logger.error("Error when closing file stream", e);
}
try {
if (fs.existsSync(path)) {
fs.unlinkSync(path);
}
} catch (e) {
logger.error("Error when deleting file", path, e);
}
// Propagate the error.
throw err;
});
}
exports.streamPixmap = streamPixmap;
exports.savePixmap = savePixmap;
}());