diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 385e2b9c..14c1a44d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -32,6 +32,8 @@ jobs: - 'packages/api-client-utils/**' signed-uploads: - 'packages/signed-uploads/**' + image-shrink: + - 'packages/image-shrink/**' - name: Install dependencies working-directory: ./ run: npm i diff --git a/package-lock.json b/package-lock.json index f1921942..a254feb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1307,22 +1307,6 @@ "@octokit/types": "^6.0.3" } }, - "node_modules/@octokit/core": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", - "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", - "dev": true, - "peer": true, - "dependencies": { - "@octokit/auth-token": "^2.4.4", - "@octokit/graphql": "^4.5.8", - "@octokit/request": "^5.6.3", - "@octokit/request-error": "^2.0.5", - "@octokit/types": "^6.0.3", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - } - }, "node_modules/@octokit/endpoint": { "version": "6.0.12", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", @@ -13253,22 +13237,6 @@ "@octokit/types": "^6.0.3" } }, - "@octokit/core": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", - "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", - "dev": true, - "peer": true, - "requires": { - "@octokit/auth-token": "^2.4.4", - "@octokit/graphql": "^4.5.8", - "@octokit/request": "^5.6.3", - "@octokit/request-error": "^2.0.5", - "@octokit/types": "^6.0.3", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - } - }, "@octokit/endpoint": { "version": "6.0.12", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", @@ -13310,8 +13278,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", - "dev": true, - "requires": {} + "dev": true }, "@octokit/plugin-rest-endpoint-methods": { "version": "3.17.0", @@ -14181,8 +14148,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "8.2.0", @@ -15716,8 +15682,7 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-node": { "version": "0.3.7", @@ -17501,8 +17466,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "29.4.3", @@ -19455,8 +19419,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/prettier-config-standard/-/prettier-config-standard-5.0.0.tgz", "integrity": "sha512-QK252QwCxlsak8Zx+rPKZU31UdbRcu9iUk9X1ONYtLSO221OgvV9TlKoTf6iPDZtvF3vE2mkgzFIEgSUcGELSQ==", - "dev": true, - "requires": {} + "dev": true }, "prettier-plugin-jsdoc": { "version": "0.4.2", @@ -19943,8 +19906,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-5.1.2.tgz", "integrity": "sha512-M32v8yPeVT0dYOYHfd6SNyl0X1xskB15jYFlwUPzIIVpLQ200KVlilbFsoNMUho4SnQuT7Di3s/aLm79bnP48w==", - "dev": true, - "requires": {} + "dev": true }, "run-async": { "version": "2.4.1", @@ -21373,8 +21335,7 @@ "ws": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "requires": {} + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==" }, "xml-name-validator": { "version": "4.0.0", diff --git a/package.json b/package.json index 2421b728..d2f70f3b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "packages/api-client-utils", "packages/upload-client", "packages/rest-client", - "packages/signed-uploads" + "packages/signed-uploads", + "packages/image-shrink" ], "engines": { "node": ">=16" @@ -17,6 +18,7 @@ "build:docs:upload-client": "typedoc --out docs/upload-client packages/upload-client/src/index.ts --readme packages/upload-client/README.md --options ./typedoc.json", "build:docs:rest-client": "typedoc --out docs/rest-client packages/rest-client/src/index.ts --readme packages/rest-client/README.md --options ./typedoc.json", "build:docs:signed-uploads": "typedoc --out docs/signed-uploads packages/signed-uploads/src/index.ts --readme packages/signed-uploads/README.md --options ./typedoc.json", + "build:docs:image-shrink": "typedoc --out docs/image-shrink packages/image-shrink/src/index.ts --readme packages/image-shrink/README.md --options ./typedoc.json", "build:docs": "rimraf docs && run-s build:docs:* && touch docs/.nojekyll", "build": "npm run build --workspaces", "lint": "run-s eslint prettier", diff --git a/packages/image-shrink/.gitignore b/packages/image-shrink/.gitignore new file mode 100644 index 00000000..6b1d0bfa --- /dev/null +++ b/packages/image-shrink/.gitignore @@ -0,0 +1 @@ +LICENSE diff --git a/packages/image-shrink/README.md b/packages/image-shrink/README.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/image-shrink/jest.config.js b/packages/image-shrink/jest.config.js new file mode 100644 index 00000000..d4c92644 --- /dev/null +++ b/packages/image-shrink/jest.config.js @@ -0,0 +1,5 @@ +import baseConfig from '../../jest.config.js' + +export default { + ...baseConfig +} diff --git a/packages/image-shrink/package.json b/packages/image-shrink/package.json new file mode 100644 index 00000000..abd875c4 --- /dev/null +++ b/packages/image-shrink/package.json @@ -0,0 +1,49 @@ +{ + "name": "@uploadcare/image-shrink", + "version": "6.10.0", + "description": "Library for work with Uploadcare image shrink", + "type": "module", + "main": "./dist/cjs/index.browser.cjs", + "module": "./dist/esm/index.browser.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/esm/index.browser.mjs", + "require": "./dist/cjs/index.browser.cjs" + } + }, + "sideEffects": false, + "files": [ + "dist/*" + ], + "scripts": { + "prepack": "cp ../../LICENSE ./LICENSE", + "clean": "rimraf dist", + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", + "prebuild": "npm run clean", + "build": "npm run build:types && npm run build:compile", + "build:types": "dts-bundle-generator --project tsconfig.dts.json -o dist/index.d.ts src/index.ts", + "build:compile": "rollup -c" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/uploadcare/uploadcare-js-api-clients.git" + }, + "author": "Uploadcare", + "license": "MIT", + "bugs": { + "url": "https://github.com/uploadcare/uploadcare-js-api-clients/issues" + }, + "homepage": "https://github.com/uploadcare/uploadcare-js-api-clients#readme", + "keywords": [ + "uploadcare", + "signed", + "uploads", + "secure", + "signature" + ], + "devDependencies": { + "ts-node": "^10.8.1" + }, + "dependencies": {} +} diff --git a/packages/image-shrink/rollup.config.js b/packages/image-shrink/rollup.config.js new file mode 100644 index 00000000..68b2bf90 --- /dev/null +++ b/packages/image-shrink/rollup.config.js @@ -0,0 +1,12 @@ +import { + createRollupConfig, + RollupTargetEnv +} from '../../createRollupConfig.js' +import * as url from 'url' + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +const config = ({ targetEnv }) => + createRollupConfig({ targetEnv, cwd: __dirname }) + +export default [...config({ targetEnv: RollupTargetEnv.BROWSER })] diff --git a/packages/image-shrink/src/.eslintrc.json b/packages/image-shrink/src/.eslintrc.json new file mode 100644 index 00000000..baf36aed --- /dev/null +++ b/packages/image-shrink/src/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "env": {"es6": true, "node": true, "browser": true} +} diff --git a/packages/image-shrink/src/constans/allowLayers.ts b/packages/image-shrink/src/constans/allowLayers.ts new file mode 100644 index 00000000..dc15b2fb --- /dev/null +++ b/packages/image-shrink/src/constans/allowLayers.ts @@ -0,0 +1,4 @@ +export const allowLayers = [ + 1, // L (black-white) + 3 // RGB +] diff --git a/packages/image-shrink/src/constans/index.ts b/packages/image-shrink/src/constans/index.ts new file mode 100644 index 00000000..5c381c78 --- /dev/null +++ b/packages/image-shrink/src/constans/index.ts @@ -0,0 +1,3 @@ +export { allowLayers } from './allowLayers' +export { markers } from './markers' +export { sizes } from './sizes' diff --git a/packages/image-shrink/src/constans/markers.ts b/packages/image-shrink/src/constans/markers.ts new file mode 100644 index 00000000..d5cec6f2 --- /dev/null +++ b/packages/image-shrink/src/constans/markers.ts @@ -0,0 +1,15 @@ +export const markers = [ + 0xc0, // ("SOF0", "Baseline DCT", SOF) + 0xc1, // ("SOF1", "Extended Sequential DCT", SOF) + 0xc2, // ("SOF2", "Progressive DCT", SOF) + 0xc3, // ("SOF3", "Spatial lossless", SOF) + 0xc5, // ("SOF5", "Differential sequential DCT", SOF) + 0xc6, // ("SOF6", "Differential progressive DCT", SOF) + 0xc7, // ("SOF7", "Differential spatial", SOF) + 0xc9, // ("SOF9", "Extended sequential DCT (AC)", SOF) + 0xca, // ("SOF10", "Progressive DCT (AC)", SOF) + 0xcb, // ("SOF11", "Spatial lossless DCT (AC)", SOF) + 0xcd, // ("SOF13", "Differential sequential DCT (AC)", SOF) + 0xce, // ("SOF14", "Differential progressive DCT (AC)", SOF) + 0xcf // ("SOF15", "Differential spatial (AC)", SOF) +] diff --git a/packages/image-shrink/src/constans/sizes.ts b/packages/image-shrink/src/constans/sizes.ts new file mode 100644 index 00000000..c89ade8d --- /dev/null +++ b/packages/image-shrink/src/constans/sizes.ts @@ -0,0 +1,45 @@ +export const sizes = { + squareSide: [ + // Safari (iOS < 9, ram >= 256) + // We are supported mobile safari < 9 since widget v2, by 5 Mpx limit + // so it's better to continue support despite the absence of this browser in the support table + Math.floor(Math.sqrt(5 * 1000 * 1000)), + // IE Mobile (Windows Phone 8.x) + // Safari (iOS >= 9) + 4096, + // IE 9 (Win) + 8192, + // Firefox 63 (Mac, Win) + 11180, + // Chrome 68 (Android 6) + 10836, + // Chrome 68 (Android 5) + 11402, + // Chrome 68 (Android 7.1-9) + 14188, + // Chrome 70 (Mac, Win) + // Chrome 68 (Android 4.4) + // Edge 17 (Win) + // Safari 7-12 (Mac) + 16384 + ], + dimension: [ + // IE Mobile (Windows Phone 8.x) + 4096, + // IE 9 (Win) + 8192, + // Edge 17 (Win) + // IE11 (Win) + 16384, + // Chrome 70 (Mac, Win) + // Chrome 68 (Android 4.4-9) + // Firefox 63 (Mac, Win) + 32767, + // Chrome 83 (Mac, Win) + // Safari 7-12 (Mac) + // Safari (iOS 9-12) + // Actually Safari has a much bigger limits - 4194303 of width and 8388607 of height, + // but we will not use them + 65535 + ] +} diff --git a/packages/image-shrink/src/helper/memoize.ts b/packages/image-shrink/src/helper/memoize.ts new file mode 100644 index 00000000..54b0b925 --- /dev/null +++ b/packages/image-shrink/src/helper/memoize.ts @@ -0,0 +1,38 @@ +export const memoize = (fn, serializer) => { + const cache = {} + return (...args) => { + const key = serializer(args, cache) + return key in cache ? cache[key] : (cache[key] = fn(...args)) + } +} + +/** + * Memoization key serealizer, that prevents unnecessary canvas tests. No need + * to make test if we know that: + * + * - Browser supports higher canvas size + * - Browser doesn't support lower canvas size + */ +export const memoKeySerializer = (args, cache) => { + const [w] = args + const cachedWidths = Object.keys(cache) + .map((val) => parseInt(val, 10)) + .sort((a, b) => a - b) + + for (let i = 0; i < cachedWidths.length; i++) { + const cachedWidth = cachedWidths[i] + const isSupported = !!cache[cachedWidth] + // higher supported canvas size, return it + if (cachedWidth > w && isSupported) { + return cachedWidth + } + // lower unsupported canvas size, return it + if (cachedWidth < w && !isSupported) { + return cachedWidth + } + } + + // use canvas width as the key, + // because we're doing dimension test by width - [dimension, 1] + return w +} diff --git a/packages/image-shrink/src/index.ts b/packages/image-shrink/src/index.ts new file mode 100644 index 00000000..b206b750 --- /dev/null +++ b/packages/image-shrink/src/index.ts @@ -0,0 +1 @@ +export { shrinkFile, type TSetting } from './utils/shrinkFile' diff --git a/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts b/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts new file mode 100644 index 00000000..3f8a8d97 --- /dev/null +++ b/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts @@ -0,0 +1,24 @@ +import { readJpegChunks } from '../image/JPEG/readJpegChunks' + +export const getIccProfile = async (file: File) => { + const iccProfile: DataView[] = [] + const { promiseReadJpegChunks, stack } = readJpegChunks() + + return await promiseReadJpegChunks(file) + .then(() => { + stack.forEach(({ marker, view }) => { + if (marker === 0xe2) { + if ( + // check for "ICC_PROFILE\0" + view.getUint32(0) === 0x4943435f && + view.getUint32(4) === 0x50524f46 && + view.getUint32(8) === 0x494c4500 + ) { + iccProfile.push(view) + } + } + }) + return iccProfile + }) + .catch(() => iccProfile) +} diff --git a/packages/image-shrink/src/utils/IccProfile/replaceIccProfile.ts b/packages/image-shrink/src/utils/IccProfile/replaceIccProfile.ts new file mode 100644 index 00000000..81abc879 --- /dev/null +++ b/packages/image-shrink/src/utils/IccProfile/replaceIccProfile.ts @@ -0,0 +1,13 @@ +import { replaceJpegChunk } from '../image/JPEG/replaceJpegChunk' + +export const MARKER = 0xe2 +export const replaceIccProfile = ( + blob: Blob | File, + iccProfiles: DataView[] +) => { + return replaceJpegChunk( + blob, + MARKER, + iccProfiles.map((chunk) => chunk.buffer) + ) +} diff --git a/packages/image-shrink/src/utils/IccProfile/stripIccProfile.ts b/packages/image-shrink/src/utils/IccProfile/stripIccProfile.ts new file mode 100644 index 00000000..cc2e5d4f --- /dev/null +++ b/packages/image-shrink/src/utils/IccProfile/stripIccProfile.ts @@ -0,0 +1,24 @@ +import { replaceIccProfile } from './replaceIccProfile' +import { imageLoader } from '../image/imageLoader' + +export const stripIccProfile = (inputFile: File): Promise => { + return new Promise((resolve, reject) => { + replaceIccProfile(inputFile, []) + .then((file: File) => { + imageLoader(URL.createObjectURL(file)) + .then((img) => { + resolve(img) + return img + }) + .then((img) => { + URL.revokeObjectURL(img.src) + }) + .catch(() => { + reject('Failed to load image') + }) + }) + .catch(() => { + reject('Failed to strip ICC profile and not image') + }) + }) +} diff --git a/packages/image-shrink/src/utils/canvas/canvasResize.ts b/packages/image-shrink/src/utils/canvas/canvasResize.ts new file mode 100644 index 00000000..4e61ba46 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/canvasResize.ts @@ -0,0 +1,22 @@ +import { createCanvas } from './createCanvas' + +export const canvasResize = (img, w, h) => { + return new Promise((resolve, reject) => { + try { + const { ctx, canvas } = createCanvas() + + canvas.width = w + canvas.height = h + + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(img, 0, 0, w, h) + + img.src = '//:0' // for image + img.width = img.height = 1 // for canvas + + resolve(canvas) + } catch (e) { + reject(`Failed to resize image. ${e}`) + } + }) +} diff --git a/packages/image-shrink/src/utils/canvas/canvasTest.ts b/packages/image-shrink/src/utils/canvas/canvasTest.ts new file mode 100644 index 00000000..ef954d29 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/canvasTest.ts @@ -0,0 +1,57 @@ +import { createCanvas } from './createCanvas' + +// add constans +const TestPixel = { + R: 55, + G: 110, + B: 165, + A: 255 +} + +const FILL_STYLE = `rgba(${TestPixel.R}, ${TestPixel.G}, ${TestPixel.B}, ${ + TestPixel.A / 255 +})` +export const canvasTest = (width, height) => { + try { + const fill = [width - 1, height - 1, 1, 1] // x, y, width, height + + const { canvas: cropCvs, ctx: cropCtx } = createCanvas() + cropCvs.width = 1 + cropCvs.height = 1 + + const { canvas: testCvs, ctx: testCtx } = createCanvas() + testCvs.width = width + testCvs.height = height + + if (testCtx) { + testCtx.fillStyle = FILL_STYLE + testCtx.fillRect.apply(testCtx, fill) + + // Render the test pixel in the bottom-right corner of the + // test canvas in the top-left of the 1x1 crop canvas. This + // dramatically reducing the time for getImageData to complete. + cropCtx.drawImage(testCvs, width - 1, height - 1, 1, 1, 0, 0, 1, 1) + } + + const imageData = cropCtx && cropCtx.getImageData(0, 0, 1, 1).data + let isTestPass = false + + if (imageData) { + // On IE10, imageData have type CanvasPixelArray, not Uint8ClampedArray. + // CanvasPixelArray supports index access operations only. + // Array buffers can't be destructuredd and compared with JSON.stringify + isTestPass = + imageData[0] === TestPixel.R && + imageData[1] === TestPixel.G && + imageData[2] === TestPixel.B && + imageData[3] === TestPixel.A + } + + testCvs.width = testCvs.height = 1 + + return isTestPass + } catch (e) { + new Error(`Failed to test for max canvas size of ${width}x${height}.`) + return false + } +} diff --git a/packages/image-shrink/src/utils/canvas/canvasToBlob.ts b/packages/image-shrink/src/utils/canvas/canvasToBlob.ts new file mode 100644 index 00000000..8faea180 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/canvasToBlob.ts @@ -0,0 +1,8 @@ +export const canvasToBlob = ( + canvas: HTMLCanvasElement, + type: string, + quality: number | undefined, + callback +): void => { + return canvas.toBlob(callback, type, quality) +} diff --git a/packages/image-shrink/src/utils/canvas/createCanvas.ts b/packages/image-shrink/src/utils/canvas/createCanvas.ts new file mode 100644 index 00000000..a6464840 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/createCanvas.ts @@ -0,0 +1,9 @@ +export const createCanvas = () => { + const canvas = document.createElement('canvas') as HTMLCanvasElement + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + + return { + canvas, + ctx + } +} diff --git a/packages/image-shrink/src/utils/canvas/hasTransparency.ts b/packages/image-shrink/src/utils/canvas/hasTransparency.ts new file mode 100644 index 00000000..ad3826b6 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/hasTransparency.ts @@ -0,0 +1,28 @@ +import { createCanvas } from "./createCanvas"; + +export const hasTransparency = (img) => { + const canvasSize = 50; + + // Create a canvas element and get 2D rendering context + const { ctx, canvas } = createCanvas(); + canvas.width = canvas.height = canvasSize; + + // Draw the image onto the canvas + ctx.drawImage(img, 0, 0, canvasSize, canvasSize); + + // Get the image data + const imageData = ctx.getImageData(0, 0, canvasSize, canvasSize).data; + + // Reset the canvas dimensions + canvas.width = canvas.height = 1; + + // Check for transparency in the alpha channel + for (let i = 3; i < imageData.length; i += 4) { + if (imageData[i] < 254) { + return true; + } + } + + // No transparency found + return false; +}; diff --git a/packages/image-shrink/src/utils/canvas/testCanvasSize.ts b/packages/image-shrink/src/utils/canvas/testCanvasSize.ts new file mode 100644 index 00000000..0f965eb7 --- /dev/null +++ b/packages/image-shrink/src/utils/canvas/testCanvasSize.ts @@ -0,0 +1,38 @@ +import { sizes } from '../../constans' +import { memoize, memoKeySerializer } from '../../helper/memoize' +import { canvasTest } from './canvasTest' + +function wrapAsync(fn) { + return (...args) => { + return new Promise((resolve) => { + setTimeout(() => { + const result = fn(...args) + resolve(result) + }, 0) + }) + } +} + +const squareTest = wrapAsync(memoize(canvasTest, memoKeySerializer)) +const dimensionTest = wrapAsync(memoize(canvasTest, memoKeySerializer)) + +export const testCanvasSize = (w, h) => { + return new Promise(async (resolve, reject) => { + const testSquareSide = sizes.squareSide.find((side) => side * side >= w * h) + const testDimension = sizes.dimension.find((side) => side >= w && side >= h) + + if (!testSquareSide || !testDimension) { + reject() + return + } + + const squareSupported = await squareTest(testSquareSide, testSquareSide) + const dimensionSupported = await dimensionTest(testDimension, 1) + + if (squareSupported && dimensionSupported) { + resolve(true) + } else { + reject() + } + }) +} diff --git a/packages/image-shrink/src/utils/devices/mobile.ts b/packages/image-shrink/src/utils/devices/mobile.ts new file mode 100644 index 00000000..1e7b4534 --- /dev/null +++ b/packages/image-shrink/src/utils/devices/mobile.ts @@ -0,0 +1,16 @@ +export const isIOS = () => { + if (/iPad|iPhone|iPod/.test(navigator.platform)) { + return true + } else { + return ( + navigator.maxTouchPoints && + navigator.maxTouchPoints > 2 && + /MacIntel/.test(navigator.platform) + ) + } +} + +export const isIpadOS = + navigator.maxTouchPoints && + navigator.maxTouchPoints > 2 && + /MacIntel/.test(navigator.platform) diff --git a/packages/image-shrink/src/utils/exif/findExifOrientation.ts b/packages/image-shrink/src/utils/exif/findExifOrientation.ts new file mode 100644 index 00000000..ea2eaede --- /dev/null +++ b/packages/image-shrink/src/utils/exif/findExifOrientation.ts @@ -0,0 +1,33 @@ +export const findExifOrientation = (exif: DataView, exifCallback) => { + let count, j, little, offset, ref + if ( + !exif || + exif.byteLength < 14 || + exif.getUint32(0) !== 0x45786966 || + exif.getUint16(4) !== 0 + ) { + return null + } + if (exif.getUint16(6) === 0x4949) { + little = true + } else if (exif.getUint16(6) === 0x4d4d) { + little = false + } else { + return null + } + if (exif.getUint16(8, little) !== 0x002a) { + return null + } + offset = 8 + exif.getUint32(10, little) + count = exif.getUint16(offset - 2, little) + for (j = 0, ref = count; ref >= 0 ? j < ref : j > ref; ref >= 0 ? ++j : --j) { + if (exif.byteLength < offset + 10) { + return null + } + if (exif.getUint16(offset, little) === 0x0112) { + return exifCallback(offset + 8, little) + } + offset += 12 + } + return null +} diff --git a/packages/image-shrink/src/utils/exif/getExif.ts b/packages/image-shrink/src/utils/exif/getExif.ts new file mode 100644 index 00000000..716a0fb7 --- /dev/null +++ b/packages/image-shrink/src/utils/exif/getExif.ts @@ -0,0 +1,27 @@ +import { readJpegChunks } from '../image/JPEG/readJpegChunks' + +export const getExif = async (file: File) => { + let isExif: DataView | null = null + + const { promiseReadJpegChunks, stack } = readJpegChunks() + return promiseReadJpegChunks(file) + .then(() => { + stack.forEach(({ marker, view }) => { + if (!isExif && marker === 0xe1) { + if (view.byteLength >= 14) { + if ( + // check for "Exif\0" + view.getUint32(0) === 0x45786966 && + view.getUint16(4) === 0 + ) { + isExif = view + return isExif + } + } + } + + return isExif + }) + }) + .catch(() => isExif) +} diff --git a/packages/image-shrink/src/utils/exif/isBrowserApplyExif.ts b/packages/image-shrink/src/utils/exif/isBrowserApplyExif.ts new file mode 100644 index 00000000..cd2fbc8e --- /dev/null +++ b/packages/image-shrink/src/utils/exif/isBrowserApplyExif.ts @@ -0,0 +1,25 @@ +const base64ImageSrc = + 'data:image/jpg;base64,' + + '/9j/4AAQSkZJRgABAQEASABIAAD/4QA6RXhpZgAATU0AKgAAAAgAAwESAAMAAAABAAYAAAEo' + + 'AAMAAAABAAIAAAITAAMAAAABAAEAAAAAAAD/2wBDAP//////////////////////////////' + + '////////////////////////////////////////////////////////wAALCAABAAIBASIA' + + '/8QAJgABAAAAAAAAAAAAAAAAAAAAAxABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAAPwBH/9k=' + +let isApplied +export const isBrowserApplyExif = () => { + return new Promise((resolve, reject) => { + if (isApplied !== undefined) { + resolve(isApplied) + } else { + const image = new Image() + + image.addEventListener('load', () => { + isApplied = image.naturalWidth < image.naturalHeight + image.src = '//:0' + resolve(isApplied) + }) + + image.src = base64ImageSrc + } + }) +} diff --git a/packages/image-shrink/src/utils/exif/replaceExif.ts b/packages/image-shrink/src/utils/exif/replaceExif.ts new file mode 100644 index 00000000..e692ef42 --- /dev/null +++ b/packages/image-shrink/src/utils/exif/replaceExif.ts @@ -0,0 +1,19 @@ +import { replaceJpegChunk } from '../image/JPEG/replaceJpegChunk' +import { findExifOrientation } from './findExifOrientation' + +export const setExifOrientation = (exif, orientation) => { + findExifOrientation(exif, (offset, little) => + exif.setUint16(offset, orientation, little) + ) +} +export const replaceExif = async ( + file: File, + exif: DataView, + isExifApplied: boolean | unknown +) => { + if (isExifApplied) { + setExifOrientation(exif, 1) + } + + return replaceJpegChunk(file, 0xe1, [exif.buffer]) +} diff --git a/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts b/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts new file mode 100644 index 00000000..10a775d7 --- /dev/null +++ b/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts @@ -0,0 +1,90 @@ +type TChunk = { + startPos: number + length: number + marker: number + view: DataView +} + +export const readJpegChunks = () => { + let stack: TChunk[] = [] + const promiseReadJpegChunks = (file) => + new Promise((resolve, reject) => { + let pos + const readToView = (file, cb) => { + const reader = new FileReader() + + reader.addEventListener('load', () => { + cb(new DataView(reader.result as ArrayBuffer)) + }) + + reader.addEventListener('error', (e) => { + reject(`Reader error: ${e}`) + }) + + reader.readAsArrayBuffer(file) + } + + const readNext = () => + readToView(file.slice(pos, pos + 128), (view) => { + let i, j, ref + for ( + i = j = 0, ref = view.byteLength; + ref >= 0 ? j < ref : j > ref; + i = ref >= 0 ? ++j : --j + ) { + if (view.getUint8(i) === 0xff) { + pos += i + break + } + } + + return readNextChunk() + }) + + const readNextChunk = () => { + let startPos = pos + + return readToView(file.slice(pos, (pos += 4)), (view) => { + let length, marker + + if (view.byteLength !== 4 || view.getUint8(0) !== 0xff) { + return reject('Corrupted') + } + + marker = view?.getUint8(1) + + if (marker === 0xda) { + return resolve(true) + } + + length = view.getUint16(2) - 2 + return readToView(file.slice(pos, (pos += length)), (view) => { + if (view.byteLength !== length) { + return reject('Corrupted') + } + + stack.push({ startPos, length, marker, view }) + return readNext() + }) + }) + } + + if (!(FileReader && DataView)) { + reject('Not Support') + } + + pos = 2 + readToView(file.slice(0, 2), function (view) { + if (view.getUint16(0) !== 0xffd8) { + reject('Not jpeg') + } + + return readNext() + }) + }) + + return { + stack, + promiseReadJpegChunks + } +} diff --git a/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts b/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts new file mode 100644 index 00000000..19d79656 --- /dev/null +++ b/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts @@ -0,0 +1,49 @@ +// @ts-nocheck +import { readJpegChunks } from './readJpegChunks' + +export const replaceJpegChunk = (blob, marker, chunks) => { + return new Promise(async (resolve, reject) => { + const oldChunkPos = [] + const oldChunkLength = [] + + const { promiseReadJpegChunks, stack } = readJpegChunks() + + return await promiseReadJpegChunks(blob) + .then(() => { + stack.forEach((chunk) => { + if (chunk.marker === marker) { + oldChunkPos.push(chunk.startPos) + return oldChunkLength.push(chunk.length) + } + }) + }) + .then(() => { + const newChunks = [blob.slice(0, 2)] + + for (const chunk of chunks) { + const intro = new DataView(new ArrayBuffer(4)) + intro.setUint16(0, 0xff00 + marker) + intro.setUint16(2, chunk.byteLength + 2) + newChunks.push(intro.buffer) + newChunks.push(chunk) + } + + let pos = 2 + for (let i = 0; i < oldChunkPos.length; i++) { + if (oldChunkPos[i] > pos) { + newChunks.push(blob.slice(pos, oldChunkPos[i])) + } + pos = oldChunkPos[i] + oldChunkLength[i] + 4 + } + + newChunks.push(blob.slice(pos, blob.size)) + + const newBlob = new Blob(newChunks, { + type: blob.type + }) + + resolve(newBlob) + }) + .catch(() => reject(blob)) + }).catch(() => blob) +} diff --git a/packages/image-shrink/src/utils/image/imageLoader.ts b/packages/image-shrink/src/utils/image/imageLoader.ts new file mode 100644 index 00000000..c57e0464 --- /dev/null +++ b/packages/image-shrink/src/utils/image/imageLoader.ts @@ -0,0 +1,27 @@ +// @ts-nocheck +export const processImage = (image: HTMLImageElement, src?: string) => { + return new Promise((resolve, reject) => { + if (src) { + image.src = src + } + + if (image.complete) { + resolve(image) + } else { + image.addEventListener('load', () => { + resolve(image) + }) + image.addEventListener('error', () => { + reject(image) + }) + } + }) +} + +export const imageLoader = (image: unknown) => { + if (image.src) { + return processImage(image) + } + + return processImage(new Image(), image) +} diff --git a/packages/image-shrink/src/utils/render/fallback.ts b/packages/image-shrink/src/utils/render/fallback.ts new file mode 100644 index 00000000..74c17275 --- /dev/null +++ b/packages/image-shrink/src/utils/render/fallback.ts @@ -0,0 +1,41 @@ +// @ts-nocheck +import { testCanvasSize } from '../canvas/testCanvasSize' +import { canvasResize } from '../canvas/canvasResize' + +const calcShrinkSteps = function (sourceW, targetW, targetH, step) { + const steps = [] + let sW = targetW + let sH = targetH + + // result should include at least one target step, + // even if abs(source - target) < step * source + // just to be sure nothing will break + // if the original resolution / target resolution condition changes + do { + steps.push([sW, sH]) + sW = Math.round(sW / step) + sH = Math.round(sH / step) + } while (sW < sourceW * step) + + return steps.reverse() +} + +export const fallback = ({ img, sourceW, targetW, targetH, step }) => { + const steps = calcShrinkSteps(sourceW, targetW, targetH, step) + + return steps + .reduce((chain, [w, h]) => { + return chain + .then((canvas) => { + return testCanvasSize(w, h) + .then(() => canvas) + .catch(() => canvasResize(canvas, w, h)) + }) + .then((canvas) => { + const progress = (sourceW - w) / (sourceW - targetW) + return { canvas, progress } + }) + }, Promise.resolve(img)) + .then(({ canvas }) => canvas) + .catch((error) => Promise.reject(error)) +} diff --git a/packages/image-shrink/src/utils/render/index.ts b/packages/image-shrink/src/utils/render/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/image-shrink/src/utils/render/native.ts b/packages/image-shrink/src/utils/render/native.ts new file mode 100644 index 00000000..c778ed6f --- /dev/null +++ b/packages/image-shrink/src/utils/render/native.ts @@ -0,0 +1,4 @@ +import { canvasResize } from '../canvas/canvasResize' + +export const native = ({ img, targetW, targetH }) => + canvasResize(img, targetW, targetH) diff --git a/packages/image-shrink/src/utils/shouldSkipShrink.ts b/packages/image-shrink/src/utils/shouldSkipShrink.ts new file mode 100644 index 00000000..8e525d88 --- /dev/null +++ b/packages/image-shrink/src/utils/shouldSkipShrink.ts @@ -0,0 +1,23 @@ +import { readJpegChunks } from './image/JPEG/readJpegChunks' +import { allowLayers, markers } from '../constans' + +export const shouldSkipShrink = async (file: File) => { + let skip = false + + const { promiseReadJpegChunks, stack } = readJpegChunks() + + return await promiseReadJpegChunks(file) + .then(() => { + stack.forEach(({ marker, view }) => { + if (!skip && markers.indexOf(marker) >= 0) { + const layer = view.getUint8(5) + if (allowLayers.indexOf(layer) < 0) { + skip = true + } + } + }) + + return skip + }) + .catch(() => skip) +} diff --git a/packages/image-shrink/src/utils/shrinkFile.ts b/packages/image-shrink/src/utils/shrinkFile.ts new file mode 100644 index 00000000..803752e0 --- /dev/null +++ b/packages/image-shrink/src/utils/shrinkFile.ts @@ -0,0 +1,91 @@ +import { shrinkImage } from './shrinkImage' +import { stripIccProfile } from './IccProfile/stripIccProfile' +import { shouldSkipShrink } from './shouldSkipShrink' +import { canvasToBlob } from './canvas/canvasToBlob' +import { hasTransparency } from './canvas/hasTransparency' +import { isBrowserApplyExif } from './exif/isBrowserApplyExif' +import { getExif } from './exif/getExif' +import { getIccProfile } from './IccProfile/getIccProfile' +import { replaceExif } from './exif/replaceExif' +import { replaceIccProfile } from './IccProfile/replaceIccProfile' + +export type TSetting = { + size: number + quality?: number +} + +export const shrinkFile = (file: File, settings: TSetting): Promise => { + return new Promise(async (resolve, reject) => { + if (!(URL && DataView && Blob)) { + reject('Not support') + } + + try { + const image = await shouldSkipShrink(file) + .then((shouldSkip) => { + if (shouldSkip) { + return reject('Should skipped') + } + }) + .then(() => { + return stripIccProfile(file).catch(() => { + reject('Failed to strip ICC profile and not image') + }) + }) + + const exifList = Promise.allSettled([ + getExif(file), + isBrowserApplyExif(), + getIccProfile(file) + ]) + + exifList.then(async (results) => { + const isRejected = results.some( + (result) => result.status === 'rejected' + ) + + const [exif, isExifApplied, iccProfile] = results as { + value: any + status: string + }[] + const isJPEG = !isRejected + + return shrinkImage(image as HTMLImageElement, settings) + .then(async (canvas) => { + let format = 'image/jpeg' + let quality: number | undefined = settings?.quality || 0.8 + + if (!isJPEG && hasTransparency(canvas)) { + format = 'image/png' + quality = undefined + } + + canvasToBlob(canvas, format, quality, (blob) => { + canvas.width = canvas.height = 1 + + let replaceChain = Promise.resolve(blob) + + if (exif.value) { + replaceChain = replaceChain + .then((blob) => + replaceExif(blob, exif.value, isExifApplied.value) + ) + .catch(() => blob) + } + + if (iccProfile?.value?.length > 0) { + replaceChain = replaceChain + .then((blob) => replaceIccProfile(blob, iccProfile.value)) + .catch(() => blob) + } + + replaceChain.then(resolve).catch(() => resolve(blob)) + }) + }) + .catch(() => reject(file)) + }) + } catch (e) { + reject(`Failed to shrink image: ${e}`) + } + }) +} diff --git a/packages/image-shrink/src/utils/shrinkImage.ts b/packages/image-shrink/src/utils/shrinkImage.ts new file mode 100644 index 00000000..a203c4d4 --- /dev/null +++ b/packages/image-shrink/src/utils/shrinkImage.ts @@ -0,0 +1,41 @@ +import { testCanvasSize } from './canvas/testCanvasSize' +import { createCanvas } from './canvas/createCanvas' +import { native } from './render/native' +import { fallback } from './render/fallback' +import { isIOS, isIpadOS } from './devices/mobile' +import { TSetting } from './shrinkFile' + +export const STEP = 0.71 // should be > sqrt(0.5) + +export const shrinkImage = ( + img: HTMLImageElement, + settings: TSetting +): Promise => { + return new Promise((resolve, reject) => { + if (img.width * STEP * img.height * STEP < settings.size) { + reject('Not required') + } + + const sourceW = img.width + const sourceH = img.height + const ratio = sourceW / sourceH + + // target size shouldn't be greater than settings.size in any case + const targetW = Math.floor(Math.sqrt(settings.size * ratio)) + const targetH = Math.floor(settings.size / Math.sqrt(settings.size * ratio)) + + return testCanvasSize(targetW, targetH) + .then(() => { + const { ctx } = createCanvas() + const supportNative = 'imageSmoothingQuality' in ctx + + const useNativeScaling = supportNative && !isIOS() && !isIpadOS + + return useNativeScaling + ? native({ img, targetW, targetH }) + : fallback({ img, sourceW, targetW, targetH, step: STEP }) + }) + .then((canvas) => resolve(canvas)) + .catch(() => reject('Not supported')) + }) +} diff --git a/packages/image-shrink/tsconfig.build.json b/packages/image-shrink/tsconfig.build.json new file mode 100644 index 00000000..680cd691 --- /dev/null +++ b/packages/image-shrink/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts"], +} diff --git a/packages/image-shrink/tsconfig.dts.json b/packages/image-shrink/tsconfig.dts.json new file mode 100644 index 00000000..ca53c1d9 --- /dev/null +++ b/packages/image-shrink/tsconfig.dts.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.dts.json", + "include": ["src"], +} diff --git a/packages/image-shrink/tsconfig.json b/packages/image-shrink/tsconfig.json new file mode 100644 index 00000000..6f83eb66 --- /dev/null +++ b/packages/image-shrink/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json", +}