Test/node_modules/pica/index.js
2026-04-09 22:54:00 +07:00

700 lines
20 KiB
JavaScript

'use strict';
const assign = require('object-assign');
const webworkify = require('webworkify');
const MathLib = require('./lib/mathlib');
const Pool = require('./lib/pool');
const utils = require('./lib/utils');
const worker = require('./lib/worker');
const createStages = require('./lib/stepper');
const createRegions = require('./lib/tiler');
// Deduplicate pools & limiters with the same configs
// when user creates multiple pica instances.
const singletones = {};
let NEED_SAFARI_FIX = false;
try {
if (typeof navigator !== 'undefined' && navigator.userAgent) {
NEED_SAFARI_FIX = navigator.userAgent.indexOf('Safari') >= 0;
}
} catch (e) {}
let concurrency = 1;
if (typeof navigator !== 'undefined') {
concurrency = Math.min(navigator.hardwareConcurrency || 1, 4);
}
const DEFAULT_PICA_OPTS = {
tile: 1024,
concurrency,
features: [ 'js', 'wasm', 'ww' ],
idle: 2000,
createCanvas: function (width, height) {
let tmpCanvas = document.createElement('canvas');
tmpCanvas.width = width;
tmpCanvas.height = height;
return tmpCanvas;
}
};
const DEFAULT_RESIZE_OPTS = {
quality: 3,
alpha: false,
unsharpAmount: 0,
unsharpRadius: 0.0,
unsharpThreshold: 0
};
let CAN_NEW_IMAGE_DATA = false;
let CAN_CREATE_IMAGE_BITMAP = false;
let CAN_USE_CANVAS_GET_IMAGE_DATA = false;
let CAN_USE_OFFSCREEN_CANVAS = false;
let CAN_USE_CIB_REGION_FOR_IMAGE = false;
function workerFabric() {
return {
value: webworkify(worker),
destroy: function () {
this.value.terminate();
if (typeof window !== 'undefined') {
let url = window.URL || window.webkitURL || window.mozURL || window.msURL;
if (url && url.revokeObjectURL && this.value.objectURL) {
url.revokeObjectURL(this.value.objectURL);
}
}
}
};
}
////////////////////////////////////////////////////////////////////////////////
// API methods
function Pica(options) {
if (!(this instanceof Pica)) return new Pica(options);
this.options = assign({}, DEFAULT_PICA_OPTS, options || {});
let limiter_key = `lk_${this.options.concurrency}`;
// Share limiters to avoid multiple parallel workers when user creates
// multiple pica instances.
this.__limit = singletones[limiter_key] || utils.limiter(this.options.concurrency);
if (!singletones[limiter_key]) singletones[limiter_key] = this.__limit;
// List of supported features, according to options & browser/node.js
this.features = {
js: false, // pure JS implementation, can be disabled for testing
wasm: false, // webassembly implementation for heavy functions
cib: false, // resize via createImageBitmap (only FF at this moment)
ww: false // webworkers
};
this.__workersPool = null;
// Store requested features for webworkers
this.__requested_features = [];
this.__mathlib = null;
}
Pica.prototype.init = function () {
if (this.__initPromise) return this.__initPromise;
// Test if we can create ImageData without canvas and memory copy
if (typeof ImageData !== 'undefined' && typeof Uint8ClampedArray !== 'undefined') {
try {
/* eslint-disable no-new */
new ImageData(new Uint8ClampedArray(400), 10, 10);
CAN_NEW_IMAGE_DATA = true;
} catch (__) {}
}
// ImageBitmap can be effective in 2 places:
//
// 1. Threaded jpeg unpack (basic)
// 2. Built-in resize (blocked due problem in chrome, see issue #89)
//
// For basic use we also need ImageBitmap wo support .close() method,
// see https://developer.mozilla.org/ru/docs/Web/API/ImageBitmap
if (typeof ImageBitmap !== 'undefined') {
if (ImageBitmap.prototype && ImageBitmap.prototype.close) {
CAN_CREATE_IMAGE_BITMAP = true;
} else {
this.debug('ImageBitmap does not support .close(), disabled');
}
}
let features = this.options.features.slice();
if (features.indexOf('all') >= 0) {
features = [ 'cib', 'wasm', 'js', 'ww' ];
}
this.__requested_features = features;
this.__mathlib = new MathLib(features);
// Check WebWorker support if requested
if (features.indexOf('ww') >= 0) {
if ((typeof window !== 'undefined') && ('Worker' in window)) {
// IE <= 11 don't allow to create webworkers from string. We should check it.
// https://connect.microsoft.com/IE/feedback/details/801810/web-workers-from-blob-urls-in-ie-10-and-11
try {
let wkr = require('webworkify')(function () {});
wkr.terminate();
this.features.ww = true;
// pool uniqueness depends on pool config + webworker config
let wpool_key = `wp_${JSON.stringify(this.options)}`;
if (singletones[wpool_key]) {
this.__workersPool = singletones[wpool_key];
} else {
this.__workersPool = new Pool(workerFabric, this.options.idle);
singletones[wpool_key] = this.__workersPool;
}
} catch (__) {}
}
}
let initMath = this.__mathlib.init().then(mathlib => {
// Copy detected features
assign(this.features, mathlib.features);
});
let checkCibResize;
if (!CAN_CREATE_IMAGE_BITMAP) {
checkCibResize = Promise.resolve(false);
} else {
checkCibResize = utils.cib_support(this.options.createCanvas).then(status => {
if (this.features.cib && features.indexOf('cib') < 0) {
this.debug('createImageBitmap() resize supported, but disabled by config');
return;
}
if (features.indexOf('cib') >= 0) this.features.cib = status;
});
}
CAN_USE_CANVAS_GET_IMAGE_DATA = utils.can_use_canvas(this.options.createCanvas);
let checkOffscreenCanvas;
if (CAN_CREATE_IMAGE_BITMAP && CAN_NEW_IMAGE_DATA && features.indexOf('ww') !== -1) {
checkOffscreenCanvas = utils.worker_offscreen_canvas_support();
} else {
checkOffscreenCanvas = Promise.resolve(false);
}
checkOffscreenCanvas = checkOffscreenCanvas.then(
result => { CAN_USE_OFFSCREEN_CANVAS = result; }
);
// we use createImageBitmap to crop image data and pass it to workers,
// so need to check whether function works correctly;
// https://bugs.chromium.org/p/chromium/issues/detail?id=1220671
let checkCibRegion = utils.cib_can_use_region().then(
result => { CAN_USE_CIB_REGION_FOR_IMAGE = result; }
);
// Init math lib. That's async because can load some
this.__initPromise = Promise.all([
initMath, checkCibResize, checkOffscreenCanvas, checkCibRegion
]).then(() => this);
return this.__initPromise;
};
// Call resizer in webworker or locally, depending on config
Pica.prototype.__invokeResize = function (tileOpts, opts) {
// Share cache between calls:
//
// - wasm instance
// - wasm memory object
//
opts.__mathCache = opts.__mathCache || {};
return Promise.resolve().then(() => {
if (!this.features.ww) {
// not possible to have ImageBitmap here if user disabled WW
return { data: this.__mathlib.resizeAndUnsharp(tileOpts, opts.__mathCache) };
}
return new Promise((resolve, reject) => {
let w = this.__workersPool.acquire();
if (opts.cancelToken) opts.cancelToken.catch(err => reject(err));
w.value.onmessage = ev => {
w.release();
if (ev.data.err) reject(ev.data.err);
else resolve(ev.data);
};
let transfer = [];
if (tileOpts.src) transfer.push(tileOpts.src.buffer);
if (tileOpts.srcBitmap) transfer.push(tileOpts.srcBitmap);
w.value.postMessage({
opts: tileOpts,
features: this.__requested_features,
preload: {
wasm_nodule: this.__mathlib.__
}
}, transfer);
});
});
};
// this function can return promise if createImageBitmap is used
Pica.prototype.__extractTileData = function (tile, from, opts, stageEnv, extractTo) {
if (this.features.ww && CAN_USE_OFFSCREEN_CANVAS &&
// createImageBitmap doesn't work for images (Image, ImageBitmap) with Exif orientation in Chrome,
// can use canvas because canvas doesn't have orientation;
// see https://bugs.chromium.org/p/chromium/issues/detail?id=1220671
(utils.isCanvas(from) || CAN_USE_CIB_REGION_FOR_IMAGE)) {
this.debug('Create tile for OffscreenCanvas');
return createImageBitmap(stageEnv.srcImageBitmap || from, tile.x, tile.y, tile.width, tile.height)
.then(bitmap => {
extractTo.srcBitmap = bitmap;
return extractTo;
});
}
// Extract tile RGBA buffer, depending on input type
if (utils.isCanvas(from)) {
if (!stageEnv.srcCtx) stageEnv.srcCtx = from.getContext('2d', { alpha: Boolean(opts.alpha) });
// If input is Canvas - extract region data directly
this.debug('Get tile pixel data');
extractTo.src = stageEnv.srcCtx.getImageData(tile.x, tile.y, tile.width, tile.height).data;
return extractTo;
}
// If input is Image or decoded to ImageBitmap,
// draw region to temporary canvas and extract data from it
//
// Note! Attempt to reuse this canvas causes significant slowdown in chrome
//
this.debug('Draw tile imageBitmap/image to temporary canvas');
let tmpCanvas = this.options.createCanvas(tile.width, tile.height);
let tmpCtx = tmpCanvas.getContext('2d', { alpha: Boolean(opts.alpha) });
tmpCtx.globalCompositeOperation = 'copy';
tmpCtx.drawImage(stageEnv.srcImageBitmap || from,
tile.x, tile.y, tile.width, tile.height,
0, 0, tile.width, tile.height);
this.debug('Get tile pixel data');
extractTo.src = tmpCtx.getImageData(0, 0, tile.width, tile.height).data;
// Safari 12 workaround
// https://github.com/nodeca/pica/issues/199
tmpCanvas.width = tmpCanvas.height = 0;
return extractTo;
};
Pica.prototype.__landTileData = function (tile, result, stageEnv) {
let toImageData;
this.debug('Convert raw rgba tile result to ImageData');
if (result.bitmap) {
stageEnv.toCtx.drawImage(result.bitmap, tile.toX, tile.toY);
return null;
}
if (CAN_NEW_IMAGE_DATA) {
// this branch is for modern browsers
// If `new ImageData()` & Uint8ClampedArray suported
toImageData = new ImageData(new Uint8ClampedArray(result.data), tile.toWidth, tile.toHeight);
} else {
// fallback for `node-canvas` and old browsers
// (IE11 has ImageData but does not support `new ImageData()`)
toImageData = stageEnv.toCtx.createImageData(tile.toWidth, tile.toHeight);
if (toImageData.data.set) {
toImageData.data.set(result.data);
} else {
// IE9 don't have `.set()`
for (let i = toImageData.data.length - 1; i >= 0; i--) {
toImageData.data[i] = result.data[i];
}
}
}
this.debug('Draw tile');
if (NEED_SAFARI_FIX) {
// Safari draws thin white stripes between tiles without this fix
stageEnv.toCtx.putImageData(toImageData, tile.toX, tile.toY,
tile.toInnerX - tile.toX, tile.toInnerY - tile.toY,
tile.toInnerWidth + 1e-5, tile.toInnerHeight + 1e-5);
} else {
stageEnv.toCtx.putImageData(toImageData, tile.toX, tile.toY,
tile.toInnerX - tile.toX, tile.toInnerY - tile.toY,
tile.toInnerWidth, tile.toInnerHeight);
}
return null;
};
Pica.prototype.__tileAndResize = function (from, to, opts) {
let stageEnv = {
srcCtx: null,
srcImageBitmap: null,
isImageBitmapReused: false,
toCtx: null
};
const processTile = (tile => this.__limit(() => {
if (opts.canceled) return opts.cancelToken;
let tileOpts = {
width: tile.width,
height: tile.height,
toWidth: tile.toWidth,
toHeight: tile.toHeight,
scaleX: tile.scaleX,
scaleY: tile.scaleY,
offsetX: tile.offsetX,
offsetY: tile.offsetY,
quality: opts.quality,
alpha: opts.alpha,
unsharpAmount: opts.unsharpAmount,
unsharpRadius: opts.unsharpRadius,
unsharpThreshold: opts.unsharpThreshold
};
this.debug('Invoke resize math');
return Promise.resolve(tileOpts)
.then(tileOpts => this.__extractTileData(tile, from, opts, stageEnv, tileOpts))
.then(tileOpts => {
this.debug('Invoke resize math');
return this.__invokeResize(tileOpts, opts);
})
.then(result => {
if (opts.canceled) return opts.cancelToken;
stageEnv.srcImageData = null;
return this.__landTileData(tile, result, stageEnv);
});
}));
// Need to normalize data source first. It can be canvas or image.
// If image - try to decode in background if possible
return Promise.resolve().then(() => {
stageEnv.toCtx = to.getContext('2d', { alpha: Boolean(opts.alpha) });
if (utils.isCanvas(from)) return null;
if (utils.isImageBitmap(from)) {
stageEnv.srcImageBitmap = from;
stageEnv.isImageBitmapReused = true;
return null;
}
if (utils.isImage(from)) {
// try do decode image in background for faster next operations;
// if we're using offscreen canvas, cib is called per tile, so not needed here
if (!CAN_CREATE_IMAGE_BITMAP) return null;
this.debug('Decode image via createImageBitmap');
return createImageBitmap(from)
.then(imageBitmap => {
stageEnv.srcImageBitmap = imageBitmap;
})
// Suppress error to use fallback, if method fails
// https://github.com/nodeca/pica/issues/190
/* eslint-disable no-unused-vars */
.catch(e => null);
}
throw new Error('Pica: ".from" should be Image, Canvas or ImageBitmap');
})
.then(() => {
if (opts.canceled) return opts.cancelToken;
this.debug('Calculate tiles');
//
// Here we are with "normalized" source,
// follow to tiling
//
let regions = createRegions({
width: opts.width,
height: opts.height,
srcTileSize: this.options.tile,
toWidth: opts.toWidth,
toHeight: opts.toHeight,
destTileBorder: opts.__destTileBorder
});
let jobs = regions.map(tile => processTile(tile));
function cleanup(stageEnv) {
if (stageEnv.srcImageBitmap) {
if (!stageEnv.isImageBitmapReused) stageEnv.srcImageBitmap.close();
stageEnv.srcImageBitmap = null;
}
}
this.debug('Process tiles');
return Promise.all(jobs).then(
() => {
this.debug('Finished!');
cleanup(stageEnv); return to;
},
err => { cleanup(stageEnv); throw err; }
);
});
};
Pica.prototype.__processStages = function (stages, from, to, opts) {
if (opts.canceled) return opts.cancelToken;
let [ toWidth, toHeight ] = stages.shift();
let isLastStage = (stages.length === 0);
opts = assign({}, opts, {
toWidth,
toHeight,
// only use user-defined quality for the last stage,
// use simpler (Hamming) filter for the first stages where
// scale factor is large enough (more than 2-3)
quality: isLastStage ? opts.quality : Math.min(1, opts.quality)
});
let tmpCanvas;
if (!isLastStage) {
// create temporary canvas
tmpCanvas = this.options.createCanvas(toWidth, toHeight);
}
return this.__tileAndResize(from, (isLastStage ? to : tmpCanvas), opts)
.then(() => {
if (isLastStage) return to;
opts.width = toWidth;
opts.height = toHeight;
return this.__processStages(stages, tmpCanvas, to, opts);
})
.then(res => {
if (tmpCanvas) {
// Safari 12 workaround
// https://github.com/nodeca/pica/issues/199
tmpCanvas.width = tmpCanvas.height = 0;
}
return res;
});
};
Pica.prototype.__resizeViaCreateImageBitmap = function (from, to, opts) {
let toCtx = to.getContext('2d', { alpha: Boolean(opts.alpha) });
this.debug('Resize via createImageBitmap()');
return createImageBitmap(from, {
resizeWidth: opts.toWidth,
resizeHeight: opts.toHeight,
resizeQuality: utils.cib_quality_name(opts.quality)
})
.then(imageBitmap => {
if (opts.canceled) return opts.cancelToken;
// if no unsharp - draw directly to output canvas
if (!opts.unsharpAmount) {
toCtx.drawImage(imageBitmap, 0, 0);
imageBitmap.close();
toCtx = null;
this.debug('Finished!');
return to;
}
this.debug('Unsharp result');
let tmpCanvas = this.options.createCanvas(opts.toWidth, opts.toHeight);
let tmpCtx = tmpCanvas.getContext('2d', { alpha: Boolean(opts.alpha) });
tmpCtx.drawImage(imageBitmap, 0, 0);
imageBitmap.close();
let iData = tmpCtx.getImageData(0, 0, opts.toWidth, opts.toHeight);
this.__mathlib.unsharp_mask(
iData.data,
opts.toWidth,
opts.toHeight,
opts.unsharpAmount,
opts.unsharpRadius,
opts.unsharpThreshold
);
toCtx.putImageData(iData, 0, 0);
// Safari 12 workaround
// https://github.com/nodeca/pica/issues/199
tmpCanvas.width = tmpCanvas.height = 0;
iData = tmpCtx = tmpCanvas = toCtx = null;
this.debug('Finished!');
return to;
});
};
Pica.prototype.resize = function (from, to, options) {
this.debug('Start resize...');
let opts = assign({}, DEFAULT_RESIZE_OPTS);
if (!isNaN(options)) {
opts = assign(opts, { quality: options });
} else if (options) {
opts = assign(opts, options);
}
opts.toWidth = to.width;
opts.toHeight = to.height;
opts.width = from.naturalWidth || from.width;
opts.height = from.naturalHeight || from.height;
// Prevent stepper from infinite loop
if (to.width === 0 || to.height === 0) {
return Promise.reject(new Error(`Invalid output size: ${to.width}x${to.height}`));
}
if (opts.unsharpRadius > 2) opts.unsharpRadius = 2;
opts.canceled = false;
if (opts.cancelToken) {
// Wrap cancelToken to avoid successive resolve & set flag
opts.cancelToken = opts.cancelToken.then(
data => { opts.canceled = true; throw data; },
err => { opts.canceled = true; throw err; }
);
}
let DEST_TILE_BORDER = 3; // Max possible filter window size
opts.__destTileBorder = Math.ceil(Math.max(DEST_TILE_BORDER, 2.5 * opts.unsharpRadius|0));
return this.init().then(() => {
if (opts.canceled) return opts.cancelToken;
// if createImageBitmap supports resize, just do it and return
if (this.features.cib) {
return this.__resizeViaCreateImageBitmap(from, to, opts);
}
if (!CAN_USE_CANVAS_GET_IMAGE_DATA) {
let err = new Error('Pica: cannot use getImageData on canvas, ' +
"make sure fingerprinting protection isn't enabled");
err.code = 'ERR_GET_IMAGE_DATA';
throw err;
}
//
// No easy way, let's resize manually via arrays
//
let stages = createStages(
opts.width,
opts.height,
opts.toWidth,
opts.toHeight,
this.options.tile,
opts.__destTileBorder
);
return this.__processStages(stages, from, to, opts);
});
};
// RGBA buffer resize
//
Pica.prototype.resizeBuffer = function (options) {
const opts = assign({}, DEFAULT_RESIZE_OPTS, options);
return this.init()
.then(() => this.__mathlib.resizeAndUnsharp(opts));
};
Pica.prototype.toBlob = function (canvas, mimeType, quality) {
mimeType = mimeType || 'image/png';
return new Promise(resolve => {
if (canvas.toBlob) {
canvas.toBlob(blob => resolve(blob), mimeType, quality);
return;
}
if (canvas.convertToBlob) {
resolve(canvas.convertToBlob({
type: mimeType,
quality
}));
return;
}
// Fallback for old browsers
const asString = atob(canvas.toDataURL(mimeType, quality).split(',')[1]);
const len = asString.length;
const asBuffer = new Uint8Array(len);
for (let i = 0; i < len; i++) {
asBuffer[i] = asString.charCodeAt(i);
}
resolve(new Blob([ asBuffer ], { type: mimeType }));
});
};
Pica.prototype.debug = function () {};
module.exports = Pica;