[bug] Move from jimp to sharp; Fix bug 76854, 76727, 48506

This commit is contained in:
Sergey Konovalov
2025-09-23 02:06:39 +03:00
parent e91f27b3e7
commit 28c44687a5
10 changed files with 327 additions and 961 deletions

View File

@ -33,11 +33,9 @@
- cron 1.5.0 ([MIT](https://raw.githubusercontent.com/kelektiv/node-cron/main/LICENSE))
- dmdb 1.0.36002 ([none](https://www.npmjs.com/package/dmdb))
- ejs 3.1.10 ([Apache-2.0](https://raw.githubusercontent.com/mde/ejs/main/LICENSE))
- exif-parser 0.1.12 ([MIT](https://raw.githubusercontent.com/bwindels/exif-parser/master/LICENSE.md))
- express 4.21.2 ([MIT](https://raw.githubusercontent.com/expressjs/express/master/LICENSE))
- fakeredis 2.0.0 ([MIT](https://github.com/hdachev/fakeredis?tab=readme-ov-file#license))
- ioredis 5.6.0 ([MIT](https://raw.githubusercontent.com/redis/ioredis/main/LICENSE))
- jimp 0.22.10 ([MIT](https://raw.githubusercontent.com/jimp-dev/jimp/main/LICENSE))
- jsonwebtoken 9.0.2 ([MIT](https://raw.githubusercontent.com/auth0/node-jsonwebtoken/master/LICENSE))
- mime 2.3.1 ([MIT](https://raw.githubusercontent.com/broofa/mime/main/LICENSE))
- mime-db 1.53.0 ([MIT](https://raw.githubusercontent.com/jshttp/mime-db/master/LICENSE))
@ -51,6 +49,7 @@
- pg 8.14.0 ([MIT](https://raw.githubusercontent.com/brianc/node-postgres/master/LICENSE))
- redis 4.7.0 ([MIT](https://raw.githubusercontent.com/redis/node-redis/master/LICENSE))
- retry 0.13.1 ([MIT](https://raw.githubusercontent.com/tim-kos/node-retry/master/License))
- sharp 0.33.5 ([Apache-2.0](https://raw.githubusercontent.com/lovell/sharp/master/LICENSE))
- socket.io 4.8.1 ([MIT](https://raw.githubusercontent.com/socketio/socket.io/main/LICENSE))
- underscore 1.13.7 ([MIT](https://raw.githubusercontent.com/jashkenas/underscore/master/LICENSE))
- utf7 1.0.2 ([BSD](https://www.npmjs.com/package/utf7))

View File

@ -388,7 +388,7 @@
"utils": {
"utils_common_fontdir": "null",
"utils_fonts_search_patterns": "*.ttf;*.ttc;*.otf",
"limits_image_types_upload": "jpg;jpeg;jpe;png;gif;bmp;svg;tiff;tif"
"limits_image_types_upload": "jpg;jpeg;jpe;png;gif;bmp;svg;tiff;tif;webp;heic;heif;avif"
},
"sql": {
"type": "postgres",

File diff suppressed because it is too large Load Diff

View File

@ -21,10 +21,9 @@
"cron": "1.5.0",
"dmdb": "1.0.36002",
"ejs": "3.1.10",
"exif-parser": "0.1.12",
"express": "4.21.2",
"ioredis": "5.6.0",
"jimp": "0.22.10",
"sharp": "0.33.5",
"jsonwebtoken": "9.0.2",
"mime": "2.3.1",
"mime-db": "1.53.0",
@ -55,7 +54,9 @@
"../Common/node_modules/axios/dist/node/axios.cjs"
],
"assets": [
"node_modules/oracledb/build/Release/oracledb-*.node"
"node_modules/oracledb/build/Release/oracledb-*.node",
"node_modules/@img/sharp-*/**/*",
"node_modules/@img/sharp-libvips-*/**/*"
]
}
}

View File

@ -770,9 +770,10 @@ function* commandImgurls(ctx, conn, cmd, outputData) {
let outputUrl = {url: 'error', path: 'error'};
if (data) {
data = yield utilsDocService.fixImageExifRotation(ctx, data);
// process image: fix EXIF rotation and convert unsupported formats to optimal format
data = yield utilsDocService.processImageOptimal(ctx, data);
let format = formatChecker.getImageFormat(ctx, data);
const format = formatChecker.getImageFormat(ctx, data);
let formatStr;
let isAllow = false;
if (constants.AVS_OFFICESTUDIO_FILE_UNKNOWN !== format) {
@ -792,11 +793,6 @@ function* commandImgurls(ctx, conn, cmd, outputData) {
}
}
if (isAllow) {
if (format === constants.AVS_OFFICESTUDIO_FILE_IMAGE_TIFF) {
data = yield utilsDocService.convertImageToPng(ctx, data);
format = constants.AVS_OFFICESTUDIO_FILE_IMAGE_PNG;
formatStr = formatChecker.getStringFromFormat(format);
}
let strLocalPath = 'media/' + crypto.randomBytes(16).toString('hex') + '_';
if (urlParsed) {
const urlBasename = pathModule.basename(urlParsed.pathname);

View File

@ -36,7 +36,6 @@ const co = require('co');
const utilsDocService = require('./utilsDocService');
const docsCoServer = require('./DocsCoServer');
const utils = require('./../../Common/sources/utils');
const constants = require('./../../Common/sources/constants');
const storageBase = require('./../../Common/sources/storage/storage-base');
const formatChecker = require('./../../Common/sources/formatchecker');
const commonDefines = require('./../../Common/sources/commondefines');
@ -103,7 +102,9 @@ exports.uploadImageFile = function (req, res) {
if (200 === httpStatus && docId && req.body && Buffer.isBuffer(req.body)) {
let buffer = req.body;
if (buffer.length <= tenImageSize) {
let format = formatChecker.getImageFormat(ctx, buffer);
// process image: fix EXIF rotation and convert unsupported formats to optimal format
buffer = yield utilsDocService.processImageOptimal(ctx, buffer);
const format = formatChecker.getImageFormat(ctx, buffer);
let formatStr = formatChecker.getStringFromFormat(format);
if (encrypted && PATTERN_ENCRYPTED === buffer.toString('utf8', 0, PATTERN_ENCRYPTED.length)) {
formatStr = buffer.toString('utf8', PATTERN_ENCRYPTED.length, buffer.indexOf(';', PATTERN_ENCRYPTED.length));
@ -111,18 +112,11 @@ exports.uploadImageFile = function (req, res) {
const supportedFormats = tenTypesUpload || 'jpg';
const formatLimit = formatStr && -1 !== supportedFormats.indexOf(formatStr);
if (formatLimit) {
if (format === constants.AVS_OFFICESTUDIO_FILE_IMAGE_TIFF) {
buffer = yield utilsDocService.convertImageToPng(ctx, buffer);
format = constants.AVS_OFFICESTUDIO_FILE_IMAGE_PNG;
formatStr = formatChecker.getStringFromFormat(format);
}
//a hash is written at the beginning to avoid errors during parallel upload in co-editing
const strImageName = crypto.randomBytes(16).toString('hex');
const strPathRel = 'media/' + strImageName + '.' + formatStr;
const strPath = docId + '/' + strPathRel;
buffer = yield utilsDocService.fixImageExifRotation(ctx, buffer);
yield storageBase.putObject(ctx, strPath, buffer, buffer.length);
output[strPathRel] = yield storageBase.getSignedUrl(
ctx,

View File

@ -34,12 +34,7 @@
const util = require('util');
const config = require('config');
const exifParser = require('exif-parser');
//set global window to fix issue https://github.com/photopea/UTIF.js/issues/130
if (!global.window) {
global.window = global;
}
const Jimp = require('jimp');
const sharp = require('sharp');
const locale = require('windows-locale');
const ms = require('ms');
@ -49,42 +44,103 @@ const cfgStartNotifyFrom = ms(config.get('license.warning_license_expiration'));
const cfgNotificationRuleLicenseExpirationWarning = config.get('notification.rules.licenseExpirationWarning.template');
const cfgNotificationRuleLicenseExpirationError = config.get('notification.rules.licenseExpirationError.template');
async function fixImageExifRotation(ctx, buffer) {
if (!buffer) {
return buffer;
/**
* Determine optimal format (PNG vs JPEG) for image conversion based on image characteristics.
* @param {operationContext} ctx Operation context for logging
* @param {Object} metadata Image metadata from sharp
* @returns {('png'|'jpeg')} Optimal format for conversion
*/
function determineOptimalFormat(ctx, metadata) {
// If image has alpha channel, only PNG can preserve transparency
if (metadata.hasAlpha) {
return 'png';
}
//todo move to DocService dir common
// Analyze color characteristics
const width = metadata.width || 0;
const height = metadata.height || 0;
// Small images (likely icons/logos) - prefer PNG
// Only apply when dimensions are known (greater than zero)
if (width > 0 && height > 0 && width <= 256 && height <= 256) {
return 'png';
}
// Large photographic images - prefer JPEG
if (width > 800 || height > 600) {
return 'jpeg';
}
// Default to JPEG for general compatibility and smaller file sizes
return 'jpeg';
}
/**
* Process and optimize image buffer with EXIF rotation fix and modern format conversion.
* 1. Fixes EXIF rotation and strips metadata for all images
* 2. Converts modern/unsupported formats to optimal formats:
* - WebP/HEIC/HEIF/AVIF/TIFF: Convert to optimal format (PNG or JPEG) based on image characteristics
* @param {operationContext} ctx Operation context for logging
* @param {Buffer} buffer Source image bytes
* @returns {Promise<Buffer>} Processed and optimally converted buffer or original buffer
*/
async function processImageOptimal(ctx, buffer) {
if (!buffer) return buffer;
let needsRotation = false;
try {
const parser = exifParser.create(buffer);
const exif = parser.parse();
if (exif.tags?.Orientation > 1) {
ctx.logger.debug('fixImageExifRotation remove exif and rotate:%j', exif);
buffer = convertImageTo(ctx, buffer, Jimp.AUTO);
const meta = await sharp(buffer, {failOn: 'none'}).metadata();
needsRotation = meta.orientation && meta.orientation > 1;
const fmt = (meta.format || '').toLowerCase();
// Handle modern formats that need conversion
if (fmt === 'webp' || fmt === 'heic' || fmt === 'heif' || fmt === 'avif') {
const optimalFormat = determineOptimalFormat(ctx, meta);
ctx.logger.debug('processImageOptimal: detected %s, converting to %s%s', fmt, optimalFormat,
needsRotation ? ' with EXIF rotation' : '');
const pipeline = sharp(buffer, {failOn: 'none'}).rotate();
if (optimalFormat === 'png') {
return await pipeline.png({compressionLevel: 7}).toBuffer();
} else {
return await pipeline.jpeg({quality: 90, chromaSubsampling: '4:4:4'}).toBuffer();
}
}
if (fmt === 'tiff' || fmt === 'tif') {
const optimalFormat = determineOptimalFormat(ctx, meta);
ctx.logger.debug('processImageOptimal: detected TIFF, converting to %s%s', optimalFormat,
needsRotation ? ' with EXIF rotation' : '');
const pipeline = sharp(buffer, {failOn: 'none'}).rotate();
if (optimalFormat === 'png') {
return await pipeline.png({compressionLevel: 7}).toBuffer();
} else {
return await pipeline.jpeg({quality: 90, chromaSubsampling: '4:4:4'}).toBuffer();
}
}
// For other formats, only apply EXIF rotation if needed
if (needsRotation) {
ctx.logger.debug('processImageOptimal: applying EXIF rotation to %s', fmt);
const pipeline = sharp(buffer, {failOn: 'none'}).rotate();
if (fmt === 'jpeg' || fmt === 'jpg') {
return await pipeline.jpeg({quality: 90, chromaSubsampling: '4:4:4'}).toBuffer();
}
if (fmt === 'png') {
return await pipeline.png({compressionLevel: 7}).toBuffer();
}
return await pipeline.toBuffer();
}
} catch (e) {
ctx.logger.debug('fixImageExifRotation error:%s', e.stack);
}
return buffer;
}
async function convertImageToPng(ctx, buffer) {
return await convertImageTo(ctx, buffer, Jimp.MIME_PNG);
}
async function convertImageTo(ctx, buffer, mime) {
try {
ctx.logger.debug('convertImageTo %s', mime);
const image = await Jimp.read(buffer);
//remove exif
image.bitmap.exifBuffer = undefined;
//set jpeg and png quality
//https://www.imagemagick.org/script/command-line-options.php#quality
image.quality(90);
image.deflateLevel(7);
buffer = await image.getBufferAsync(mime);
} catch (e) {
ctx.logger.debug('convertImageTo error:%s', e.stack);
ctx.logger.debug('processImageOptimal error:%s', e.stack);
}
return buffer;
}
/**
*
* @param {string} lang
@ -143,7 +199,7 @@ async function notifyLicenseExpiration(ctx, endDate) {
}
}
module.exports.fixImageExifRotation = fixImageExifRotation;
module.exports.convertImageToPng = convertImageToPng;
module.exports.processImageOptimal = processImageOptimal;
module.exports.determineOptimalFormat = determineOptimalFormat;
module.exports.localeToLCID = localeToLCID;
module.exports.notifyLicenseExpiration = notifyLicenseExpiration;

View File

@ -32,8 +32,6 @@
"code:check": "run-s lint:check format:check",
"code:fix": "run-s lint:fix format:fix",
"perf-expired": "cd ./DocService&& cross-env NODE_ENV=development-windows NODE_CONFIG_DIR=../Common/config node ../tests/perf/checkFileExpire.js",
"perf-exif": "cd ./DocService&& cross-env NODE_ENV=development-windows NODE_CONFIG_DIR=../Common/config node ../tests/perf/fixImageExifRotation.js",
"perf-png": "cd ./DocService&& cross-env NODE_ENV=development-windows NODE_CONFIG_DIR=../Common/config node ../tests/perf/convertImageToPng.js",
"unit tests": "cd ./DocService && jest unit --inject-globals=false --config=../tests/jest.config.js",
"integration tests with server instance": "cd ./DocService && jest integration/withServerInstance --inject-globals=false --config=../tests/jest.config.js",
"storage-tests": "cd ./DocService && jest integration/withServerInstance/storage.tests.js --inject-globals=false --config=../tests/jest.config.js",

View File

@ -1,125 +0,0 @@
/*
* (c) Copyright Ascensio System SIA 2010-2023
*
* This program is a free software product. You can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License (AGPL)
* version 3 as published by the Free Software Foundation. In accordance with
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
* that Ascensio System SIA expressly excludes the warranty of non-infringement
* of any third-party rights.
*
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
*
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
* street, Riga, Latvia, EU, LV-1050.
*
* The interactive user interfaces in modified source and object code versions
* of the Program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU AGPL version 3.
*
* Pursuant to Section 7(b) of the License you must retain the original Product
* logo when distributing the program. Pursuant to Section 7(e) we decline to
* grant you any rights under trademark law for use of our trademarks.
*
* All the Product's GUI elements, including illustrations and icon sets, as
* well as technical writing content are licensed under the terms of the
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
*
*/
'use strict';
const {createHistogram, performance, PerformanceObserver} = require('node:perf_hooks');
const {readdir, mkdir, readFile, writeFile} = require('node:fs/promises');
const path = require('path');
// const Jimp = require('Jimp');
const utils = require('./../../Common/sources/utils');
const operationContext = require('./../../Common/sources/operationContext');
const utilsDocService = require('./../../DocService/sources/utilsDocService');
const ctx = operationContext.global;
const histograms = {};
async function beforeStart() {
const timerify = function (func) {
const histogram = createHistogram();
histograms[func.name] = histogram;
return performance.timerify(func, {histogram});
};
utilsDocService.convertImageToPng = timerify(utilsDocService.convertImageToPng);
// Jimp.read = timerify(Jimp.read);
const obs = new PerformanceObserver(list => {
const entries = list.getEntries();
entries.forEach(entry => {
const duration = Math.round(entry.duration * 1000) / 1000;
console.log(`${entry.name}:${duration}ms`);
});
});
obs.observe({entryTypes: ['function']});
}
async function beforeEnd() {
const logHistogram = function (histogram, name) {
const mean = Math.round(histogram.mean / 1000) / 1000;
const min = Math.round(histogram.min / 1000) / 1000;
const max = Math.round(histogram.max / 1000) / 1000;
const count = histogram.count;
ctx.logger.info(`histogram ${name}: count=${count}, mean=${mean}ms, min=${min}ms, max=${max}ms`);
};
await utils.sleep(1000);
for (const name in histograms) {
logHistogram(histograms[name], name);
}
}
async function fixInDir(dirIn, dirOut) {
ctx.logger.info('dirIn:%s', dirIn);
ctx.logger.info('dirOut:%s', dirOut);
const dirents = await readdir(dirIn, {withFileTypes: true, recursive: true});
for (const dirent of dirents) {
if (dirent.isFile()) {
const file = dirent.name;
ctx.logger.info('fixInDir:%s', file);
const buffer = await readFile(path.join(dirent.path, file));
const bufferNew = await utilsDocService.convertImageToPng(ctx, buffer);
if (buffer !== bufferNew) {
const outputPath = path.join(dirOut, dirent.path.substring(dirIn.length), path.basename(file, path.extname(file)) + '.png');
await mkdir(path.dirname(outputPath), {recursive: true});
await writeFile(outputPath, bufferNew);
}
}
}
}
async function startTest() {
const args = process.argv.slice(2);
if (args.length < 2) {
ctx.logger.error('missing arguments.USAGE: convertImageToPng.js "dirIn" "dirOut"');
return;
}
ctx.logger.info('test started');
await beforeStart();
await fixInDir(args[0], args[1]);
await beforeEnd();
ctx.logger.info('test finished');
}
startTest()
.then(() => {
//delay to log observer events
return utils.sleep(1000);
})
.catch(err => {
ctx.logger.error(err.stack);
})
.finally(() => {
process.exit(0);
});

View File

@ -1,125 +0,0 @@
/*
* (c) Copyright Ascensio System SIA 2010-2024
*
* This program is a free software product. You can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License (AGPL)
* version 3 as published by the Free Software Foundation. In accordance with
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
* that Ascensio System SIA expressly excludes the warranty of non-infringement
* of any third-party rights.
*
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
*
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
* street, Riga, Latvia, EU, LV-1050.
*
* The interactive user interfaces in modified source and object code versions
* of the Program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU AGPL version 3.
*
* Pursuant to Section 7(b) of the License you must retain the original Product
* logo when distributing the program. Pursuant to Section 7(e) we decline to
* grant you any rights under trademark law for use of our trademarks.
*
* All the Product's GUI elements, including illustrations and icon sets, as
* well as technical writing content are licensed under the terms of the
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
*
*/
'use strict';
const {createHistogram, performance, PerformanceObserver} = require('node:perf_hooks');
const {readdir, mkdir, readFile, writeFile} = require('node:fs/promises');
const path = require('path');
// const Jimp = require('Jimp');
const utils = require('./../../Common/sources/utils');
const operationContext = require('./../../Common/sources/operationContext');
const utilsDocService = require('./../../DocService/sources/utilsDocService');
const ctx = operationContext.global;
const histograms = {};
async function beforeStart() {
const timerify = function (func) {
const histogram = createHistogram();
histograms[func.name] = histogram;
return performance.timerify(func, {histogram});
};
utilsDocService.fixImageExifRotation = timerify(utilsDocService.fixImageExifRotation);
// Jimp.read = timerify(Jimp.read);
const obs = new PerformanceObserver(list => {
const entries = list.getEntries();
entries.forEach(entry => {
const duration = Math.round(entry.duration * 1000) / 1000;
console.log(`${entry.name}:${duration}ms`);
});
});
obs.observe({entryTypes: ['function']});
}
async function beforeEnd() {
const logHistogram = function (histogram, name) {
const mean = Math.round(histogram.mean / 1000) / 1000;
const min = Math.round(histogram.min / 1000) / 1000;
const max = Math.round(histogram.max / 1000) / 1000;
const count = histogram.count;
ctx.logger.info(`histogram ${name}: count=${count}, mean=${mean}ms, min=${min}ms, max=${max}ms`);
};
await utils.sleep(1000);
for (const name in histograms) {
logHistogram(histograms[name], name);
}
}
async function fixInDir(dirIn, dirOut) {
ctx.logger.info('dirIn:%s', dirIn);
ctx.logger.info('dirOut:%s', dirOut);
const dirents = await readdir(dirIn, {withFileTypes: true, recursive: true});
for (const dirent of dirents) {
if (dirent.isFile()) {
const file = dirent.name;
ctx.logger.info('fixInDir:%s', file);
const buffer = await readFile(path.join(dirent.path, file));
const bufferNew = await utilsDocService.fixImageExifRotation(ctx, buffer);
if (buffer !== bufferNew) {
const outputPath = path.join(dirOut, dirent.path.substring(dirIn.length), file);
await mkdir(path.dirname(outputPath), {recursive: true});
await writeFile(outputPath, bufferNew);
}
}
}
}
async function startTest() {
const args = process.argv.slice(2);
if (args.length < 2) {
ctx.logger.error('missing arguments.USAGE: fixImageExifRotation.js "dirIn" "dirOut"');
return;
}
ctx.logger.info('test started');
await beforeStart();
await fixInDir(args[0], args[1]);
await beforeEnd();
ctx.logger.info('test finished');
}
startTest()
.then(() => {
//delay to log observer events
return utils.sleep(1000);
})
.catch(err => {
ctx.logger.error(err.stack);
})
.finally(() => {
process.exit(0);
});