diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..13ec1f2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +other/ +dist/ +lib/ diff --git a/.github/workflows/manual_bump_version.yml b/.github/workflows/manual_bump_version.yml index 3e2f6aa..0a52c81 100644 --- a/.github/workflows/manual_bump_version.yml +++ b/.github/workflows/manual_bump_version.yml @@ -42,10 +42,10 @@ jobs: - name: Major version if: ${{ inputs.semver == 'major' }} - run: npm run bump-major + run: npm run bump:major - name: Minor version if: ${{ inputs.semver == 'minor' }} - run: npm run bump-minor + run: npm run bump:minor - name: Patch version if: ${{ inputs.semver == 'patch' }} - run: npm run bump-patch + run: npm run bump:patch diff --git a/.github/workflows/test_on_pull_request.yml b/.github/workflows/test_on_pull_request.yml index 6dab164..ede84dd 100644 --- a/.github/workflows/test_on_pull_request.yml +++ b/.github/workflows/test_on_pull_request.yml @@ -10,13 +10,13 @@ jobs: strategy: fail-fast: false matrix: - node-version: [12.x, 14.x, 16.x, 18.x] + node-version: [14.x, 16.x, 18.x] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - cache: 'npm' + # cache: 'npm' - run: npm install - run: npm run lint - run: npm run build diff --git a/.gitignore b/.gitignore index 758e21c..6fcea33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -.vscode -*.code-workspace +other/ +.vscode/ node_modules -dist -lib +dist/ +lib/ package-lock.json *.tgz diff --git a/.prettierignore b/.prettierignore index 48f3833..be73855 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,7 @@ -dist -lib -.vscode -*.code-workspace +other/ +dist/ +lib/ +.vscode/ node_modules package-lock.json *.tgz diff --git a/package.json b/package.json index 3345199..a540c5d 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ } ], "type": "module", - "main": "./dist/index.cjs.js", + "main": "./dist/index.js", "module": "./dist/index.esm.js", "types": "./dist/index.d.ts", "exports": { @@ -71,28 +71,44 @@ }, "scripts": { "lint": "eslint ./src/", - "lint:ci": "eslint ./src/ --fix", - "dev": "cross-env NODE_ENV=development tsup", - "build": "cross-env NODE_ENV=production tsup", + "lint:fix": "eslint ./src/ --fix", + "build:watch": "cross-env NODE_ENV=development rollup --config rollup.config.js --watch --sourcemap inline", + "build:dev": "cross-env NODE_ENV=development rollup --config rollup.config.js --sourcemap inline", + "build": "cross-env NODE_ENV=production rollup --config rollup.config.js", "test": "ava", - "bump-patch": "npm version patch -m \"Patch version %s\"", - "bump-minor": "npm version minor -m \"Minor version %s\"", - "bump-major": "npm version major -m \"Major version %s\"", + "server": "nodemon --watch ./src/serve.js --ext js ./src/serve.js", + "start": "concurrently --names \"ROLLUP,SERVER\" -c \"bgRed.bold,bgBlue.bold\" \"npm:build:watch\" \"npm:server\"", + "bump:patch": "npm version patch -m \"Patch version %s\"", + "bump:minor": "npm version minor -m \"Minor version %s\"", + "bump:major": "npm version major -m \"Major version %s\"", "preversion": "npm run lint && npm run build && npm run test", "version": "git add .", "postversion": "git push --follow-tags" }, "devDependencies": { + "@rollup/plugin-typescript": "^11.0.0", + "@types/connect-livereload": "^0.6.0", + "@types/express": "^4.17.15", + "@types/livereload": "^0.9.2", "@types/node": "^18.11.18", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "ava": "^5.1.0", + "concurrently": "^7.6.0", + "connect-livereload": "^0.6.1", "cross-env": "^7.0.3", + "esbuild": "^0.16.16", "eslint": "^8.31.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-prettier": "^4.2.1", + "express": "^4.18.2", + "livereload": "^0.9.3", + "nodemon": "^2.0.20", "prettier": "^2.8.2", - "tsup": "^6.5.0", + "rollup": "^3.9.1", + "rollup-plugin-dts": "^5.1.1", + "rollup-plugin-esbuild": "^5.0.0", + "tslib": "^2.4.1", "typescript": "^4.9.4" }, "publishConfig": { diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..f24959a --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,92 @@ +// Install: +// npm install -D @rollup/plugin-terser @rollup/plugin-commonjs +// import typescript from '@rollup/plugin-typescript' +// import terser from '@rollup/plugin-terser' +// import commonjs from '@rollup/plugin-commonjs' +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +// import {RollupOptions} from 'rollup' + +const isProduction = process.env.NODE_ENV === 'production' + +// See: https://rollupjs.org/guide/en/#outputformat + +export default [ + { + input: 'src/index.ts', + // plugins: [typescript(), isProduction && terser()], + plugins: [ + esbuild({ + sourceMap: !isProduction, + minify: isProduction, + }), + ], + output: [ + { + file: `${isProduction ? 'dist' : 'lib'}/index.esm.js`, + format: 'esm', + // sourcemap: isProduction, + }, + { + name: 'isApng', + file: `${isProduction ? 'dist' : 'lib'}/index.cjs.js`, + format: 'cjs', + // interop: 'auto', + // sourcemap: isProduction, + }, + { + name: 'isApng', + file: `${isProduction ? 'dist' : 'lib'}/index.js`, + format: 'umd', + // sourcemap: isProduction, + }, + ], + }, + // { + // input: 'src/index.ts', + // plugins: [ + // esbuild({ + // // All options are optional + // include: /\.[jt]sx?$/, // default, inferred from `loaders` option + // exclude: /node_modules/, // default + // sourceMap: true, // default + // // minify: true, + // target: 'es2017', // default, or 'es20XX', 'esnext' + // // jsx: 'transform', // default, or 'preserve' + // // jsxFactory: 'React.createElement', + // // jsxFragment: 'React.Fragment', + // // Like @rollup/plugin-replace + // // define: { + // // __VERSION__: '"x.y.z"', + // // }, + // // tsconfig: 'tsconfig.json', // default + // // Add extra loaders + // // loaders: { + // // // Add .json files support + // // // require @rollup/plugin-commonjs + // // '.json': 'json', + // // // Enable JSX in .js files too + // // '.js': 'jsx', + // // }, + // }), + // // typescript(), + // // commonjs(), + // // isProduction && terser() + // ], + // output: { + // name: 'isApng', + // file: `${isProduction ? 'dist' : 'lib'}/index.cjs.js`, + // format: 'cjs', + // // interop: 'default', + // interop: 'auto', + // }, + // }, + { + input: `src/index.ts`, + plugins: [dts()], + output: { + file: `${isProduction ? 'dist' : 'lib'}/index.d.ts`, + format: 'es', + }, + }, +] diff --git a/src/images/static.png b/src/images/static.png index bd1479d..8597e68 100644 Binary files a/src/images/static.png and b/src/images/static.png differ diff --git a/src/index.ts b/src/index.ts index 8a11b07..5c00e55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,112 +1,12 @@ -import { Buffer } from 'node:buffer' - -/** - * Returns the index of the first occurrence of a sequence in an typed array, or -1 if it is not present. - * - * Works similar to `Array.prototype.indexOf()`, but it searches for a sequence of array values (bytes). - * The bytes in the `haystack` array are decoded (UTF-8) and then used to search for `needle`. - * - * @param haystack `Uint8Array` - * Array to search in. - * - * @param needle `string | RegExp` - * The value to locate in the array. - * - * @param fromIndex `number` - * The array index at which to begin the search. - * - * @param upToIndex `number` - * The array index up to which to search. - * If omitted, search until the end. - * - * @param chunksize `number` - * Size of the chunks used when searching (default 1024). - * - * @returns boolean - * Whether the array holds Animated PNG data. - */ -function indexOfSubstring( - haystack: Uint8Array, - needle: string | RegExp, - fromIndex: number, - upToIndex?: number, - chunksize = 1024 /* Bytes */, -) { - /** - * Adopted from: https://stackoverflow.com/a/67771214/2142071 - */ - - if (!needle) { - return -1 - } - needle = new RegExp(needle, 'g') - - // The needle could get split over two chunks. - // So, at every chunk we prepend the last few characters - // of the last chunk. - const needle_length = needle.source.length - const decoder = new TextDecoder() - - // Handle search offset in line with - // `Array.prototype.indexOf()` and `TypedArray.prototype.subarray()`. - const full_haystack_length = haystack.length - if (typeof upToIndex === 'undefined') { - upToIndex = full_haystack_length - } - if ( - fromIndex >= full_haystack_length || - upToIndex <= 0 || - fromIndex >= upToIndex - ) { - return -1 - } - haystack = haystack.subarray(fromIndex, upToIndex) - - let position = -1 - let current_index = 0 - let full_length = 0 - let needle_buffer = '' - - outer: while (current_index < haystack.length) { - const next_index = current_index + chunksize - // subarray doesn't copy - const chunk = haystack.subarray(current_index, next_index) - const decoded = decoder.decode(chunk, { stream: true }) - - const text = needle_buffer + decoded - - let match: RegExpExecArray | null - let last_index = -1 - while ((match = needle.exec(text)) !== null) { - last_index = match.index - needle_buffer.length - position = full_length + last_index - break outer - } - - current_index = next_index - full_length += decoded.length - - // Check that the buffer doesn't itself include the needle - // this would cause duplicate finds (we could also use a Set to avoid that). - const needle_index = - last_index > -1 - ? last_index + needle_length - : decoded.length - needle_length - needle_buffer = decoded.slice(needle_index) - } - - // Correct for search offset. - if (position >= 0) { - position += fromIndex >= 0 ? fromIndex : full_haystack_length + fromIndex - } - - return position -} +// import { Buffer } from 'node:buffer' -export default function isApng(buffer: Buffer | Uint8Array) { +export default function isApng(buffer: Buffer | Uint8Array): boolean { if ( !buffer || - !(Buffer.isBuffer(buffer) || buffer instanceof Uint8Array) || + !( + (typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) || + buffer instanceof Uint8Array + ) || buffer.length < 16 ) { return false @@ -126,6 +26,109 @@ export default function isApng(buffer: Buffer | Uint8Array) { return false } + /** + * Returns the index of the first occurrence of a sequence in an typed array, or -1 if it is not present. + * + * Works similar to `Array.prototype.indexOf()`, but it searches for a sequence of array values (bytes). + * The bytes in the `haystack` array are decoded (UTF-8) and then used to search for `needle`. + * + * @param haystack `Uint8Array` + * Array to search in. + * + * @param needle `string | RegExp` + * The value to locate in the array. + * + * @param fromIndex `number` + * The array index at which to begin the search. + * + * @param upToIndex `number` + * The array index up to which to search. + * If omitted, search until the end. + * + * @param chunksize `number` + * Size of the chunks used when searching (default 1024). + * + * @returns boolean + * Whether the array holds Animated PNG data. + */ + function indexOfSubstring( + haystack: Uint8Array, + needle: string | RegExp, + fromIndex: number, + upToIndex?: number, + chunksize = 1024 /* Bytes */, + ) { + /** + * Adopted from: https://stackoverflow.com/a/67771214/2142071 + */ + + if (!needle) { + return -1 + } + needle = new RegExp(needle, 'g') + + // The needle could get split over two chunks. + // So, at every chunk we prepend the last few characters + // of the last chunk. + const needle_length = needle.source.length + const decoder = new TextDecoder() + + // Handle search offset in line with + // `Array.prototype.indexOf()` and `TypedArray.prototype.subarray()`. + const full_haystack_length = haystack.length + if (typeof upToIndex === 'undefined') { + upToIndex = full_haystack_length + } + if ( + fromIndex >= full_haystack_length || + upToIndex <= 0 || + fromIndex >= upToIndex + ) { + return -1 + } + haystack = haystack.subarray(fromIndex, upToIndex) + + let position = -1 + let current_index = 0 + let full_length = 0 + let needle_buffer = '' + + outer: while (current_index < haystack.length) { + const next_index = current_index + chunksize + // subarray doesn't copy + const chunk = haystack.subarray(current_index, next_index) + const decoded = decoder.decode(chunk, { stream: true }) + + const text = needle_buffer + decoded + + let match: RegExpExecArray | null + let last_index = -1 + while ((match = needle.exec(text)) !== null) { + last_index = match.index - needle_buffer.length + position = full_length + last_index + break outer + } + + current_index = next_index + full_length += decoded.length + + // Check that the buffer doesn't itself include the needle + // this would cause duplicate finds (we could also use a Set to avoid that). + const needle_index = + last_index > -1 + ? last_index + needle_length + : decoded.length - needle_length + needle_buffer = decoded.slice(needle_index) + } + + // Correct for search offset. + if (position >= 0) { + position += fromIndex >= 0 ? fromIndex : full_haystack_length + fromIndex + } + + return position + } + // APNGs have an animation control chunk ('acTL') preceding the IDATs. // See: https://en.wikipedia.org/wiki/APNG#File_format const arr = new Uint8Array(buffer) @@ -138,6 +141,8 @@ export default function isApng(buffer: Buffer | Uint8Array) { return false } +// globalThis.isApng = isApng + // (new TextEncoder()).encode('IDAT') // Decimal: [73, 68, 65, 84] // Hex: [0x49, 0x44, 0x41, 0x54] diff --git a/src/serve.js b/src/serve.js new file mode 100644 index 0000000..4a50654 --- /dev/null +++ b/src/serve.js @@ -0,0 +1,30 @@ +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' +import livereload from 'livereload' +import connectLiveReload from 'connect-livereload' + +const dir = + typeof __dirname !== 'undefined' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) + +const liveReloadServer = livereload.createServer() +liveReloadServer.server.once('connection', () => { + setTimeout(() => { + liveReloadServer.refresh('/') + }, 100) +}) + +liveReloadServer.watch(join(dir, 'test-browser')) + +const app = express() +const port = 3000 + +app.use(connectLiveReload()) +app.use(express.static(resolve(dir, './test-browser/'))) +app.use('/js', express.static(resolve(dir, '../lib/'))) + +app.listen(port, () => { + console.log(`Example app listening on http://localhost:${port}/`) +}) diff --git a/src/test-browser/img/animated.gif b/src/test-browser/img/animated.gif new file mode 100644 index 0000000..e85642f Binary files /dev/null and b/src/test-browser/img/animated.gif differ diff --git a/src/test-browser/img/animated.png b/src/test-browser/img/animated.png new file mode 100644 index 0000000..c2f45d9 Binary files /dev/null and b/src/test-browser/img/animated.png differ diff --git a/src/test-browser/img/animated.webp b/src/test-browser/img/animated.webp new file mode 100644 index 0000000..5b44046 Binary files /dev/null and b/src/test-browser/img/animated.webp differ diff --git a/src/test-browser/img/broken.png b/src/test-browser/img/broken.png new file mode 100644 index 0000000..9cc0bb1 Binary files /dev/null and b/src/test-browser/img/broken.png differ diff --git a/src/test-browser/img/static.jpg b/src/test-browser/img/static.jpg new file mode 100644 index 0000000..9625248 Binary files /dev/null and b/src/test-browser/img/static.jpg differ diff --git a/src/test-browser/img/static.png b/src/test-browser/img/static.png new file mode 100644 index 0000000..8597e68 Binary files /dev/null and b/src/test-browser/img/static.png differ diff --git a/src/test-browser/index.html b/src/test-browser/index.html new file mode 100644 index 0000000..af39bb6 --- /dev/null +++ b/src/test-browser/index.html @@ -0,0 +1,476 @@ + + + + + + + Test isApng + + + + + + + + + + + +
+ +
+
+
+

isAPNG

+ Check if image is Animated PNG +
+
+ Test if a image is an Animated PNG file, by either: + +
+ +
+ + diff --git a/src/test-browser/style.css b/src/test-browser/style.css new file mode 100644 index 0000000..e28097c --- /dev/null +++ b/src/test-browser/style.css @@ -0,0 +1,321 @@ +*, +::before, +::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* See: https://www.a11yproject.com/posts/how-to-hide-content/ */ +.visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +html { + width: 100%; + height: 100%; +} + +body { + width: 100%; + height: 100%; + overflow-y: auto; + font-family: 'Source Code Sans', 'Segoe UI', Tahoma, Geneva, Verdana, + sans-serif; + background-color: #f1f1f1; +} + +section { + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + justify-content: flex-end; +} + +section { + position: relative; + z-index: 2; + pointer-events: none; +} + +header { + position: fixed; + left: 1em; + top: 0.25em; + width: fit-content; +} +header h1 { + font-size: 4em; +} +header small { + display: block; + width: 100%; + padding: 0 0.25em; + margin-top: -0.8em; + text-align: justify; + -moz-text-align-last: justify; + text-align-last: justify; + text-transform: uppercase; + /* + display: flex; + flex-grow: 1; + justify-content: space-between; + */ +} +#label-wrapper { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +#label { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + /* + */ + background-image: radial-gradient(rgb(255, 255, 255), hsl(206, 23%, 85%)); + transition: all 0.35s ease; +} + +#label > div { + pointer-events: none; + opacity: 0; + position: absolute; + display: flex; + flex-direction: column; + width: 0; + height: 0; + padding-bottom: 10vh; + /* + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + */ + font-size: 2em; + justify-content: center; + align-items: center; +} +#label:not(.dragging):not(.is-apng):not(.not-apng):not(.not-image) .is-empty, +#label:not(.dragging).is-apng .is-apng, +#label:not(.dragging).not-apng .not-apng, +#label.dragging .dragging, +#label:not(.dragging).not-image .not-image { + width: auto; + height: auto; + position: relative; + opacity: 1; +} + +#label.dragging { + box-shadow: inset 0 0 50vmin 0 hsl(210, 65%, 40%); +} + +#label:not(.dragging).is-apng { + box-shadow: inset 0 0 50vmin 0 hsl(145, 65%, 40%); +} + +#label:not(.dragging).not-apng { + box-shadow: inset 0 0 50vmin 0 hsl(0, 65%, 55%); +} + +#label > div img { + max-width: 50vmin; + max-height: 45vmin; +} + +#label > div .filename { + font-family: ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', 'Liberation Mono', + Menlo, Monaco, Consolas, monospace; + margin-top: 0.5em; + font-size: 0.65em; + /* background-color: #fff; */ + color: rgba(0, 0, 0, 0.5); + border-radius: 0.3em; + padding: 0.25em 0.5em; +} + +#result { + display: inline-flex; + position: relative; + width: 100%; + max-width: 450px; + min-height: 150px; + font-size: 1.8em; + padding: 1.25em 1.25em; + background-color: hsl(0, 0%, 70%); + border-radius: 0.35em; + align-items: center; + justify-content: center; +} +#result::before { + content: ''; + display: flex; + text-align: center; + color: rgba(255, 255, 255, 0.5); + /* color: rgba(255, 255, 255, 0.65); */ +} +#result::after { + /* content: '...'; */ + content: 'Click or Drop file here'; + padding-left: 0; + display: flex; + text-align: center; + font-size: 1.25em; + color: rgba(255, 255, 255, 0.85); + /* color: rgba(255, 255, 255, 0.3); */ + font-weight: bold; +} +#result:hover { + background-color: hsl(210, 65%, 40%); +} +#result:hover::after { + content: 'Click or Drop file here'; + padding-left: 0; +} +#result.is-apng { + background-color: hsl(145, 65%, 40%); +} +#result.is-apng::before { + content: '✓'; +} +#result.is-apng::after { + content: 'File is APNG'; + padding-left: 2em; +} +#result.not-apng { + background-color: hsl(0, 65%, 55%); +} +#result.not-apng::before { + content: '✗'; +} +#result.not-apng::after { + content: 'File is not APNG'; + padding-left: 2em; +} +#result.is-apng::before, +#result.not-apng::before { + position: absolute; + left: 0.85em; + top: 0.55em; + display: inline-block; + align-self: center; + font-weight: bold; + font-size: 200%; +} + +#info { + position: fixed; + left: 1em; + bottom: 1em; + max-width: calc(100% - 2em); + max-height: calc(100% - 2em); + color: #333; + background-color: rgba(255, 255, 255, 0.6); + padding: 1.5em; + font-size: 1.125rem; + border-radius: 0.5em; +} +#info ul { + padding-top: 0.3em; + list-style: none; +} +#info ul li { + position: relative; + margin-top: 0.5em; + padding-left: 1.5em; + line-height: 1; + font-size: 1rem; +} +#info ul li::before { + --size: 0.35em; + + content: ''; + position: absolute; + left: calc(0.75em - 0.5 * var(--size)); + top: calc(0.5em - 0.5 * var(--size)); + display: inline-flex; + width: var(--size); + height: var(--size); + background-color: rgba(0, 0, 0, 0.15); + border-radius: 50%; +} + +#images { + position: fixed; + top: 0; + bottom: 0; + right: 0; + width: 100%; + max-width: calc(100px + 3em); + padding: 1em; + display: flex; + flex-direction: column; + gap: 2em; + list-style: none; + justify-content: flex-start; + align-items: flex-end; + overflow: hidden; + /* + overflow-x: hidden; + overflow-y: auto; + flex-wrap: wrap; + */ +} + +#images li { + pointer-events: initial; + display: flex; + flex-direction: column; + align-items: center; +} + +#images span:nth-child(1) { + display: flex; + width: 120px; + height: 100px; + justify-content: center; + align-items: center; +} + +#images span:nth-child(2) { + font-size: smaller; + background: rgba(255, 255, 255, 0.5); + margin-top: 1em; + padding: 0.25em 0.5em 0.3em; + border-radius: 0.5em; +} + +#images img { + display: flex; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +#images img { + cursor: move; + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} +#images img.grabbed { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} diff --git a/tsup.config.ts b/tsup.config.ts deleted file mode 100644 index 1915bd8..0000000 --- a/tsup.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineConfig } from 'tsup' -// import type { Options } from 'tsup' - -const env = process.env.NODE_ENV - -export default defineConfig((/* options: Options */) => { - return { - outExtension({ format }) { - return { - js: `.${format}.js`, - } - }, - splitting: true, - clean: true, // clean up the dist folder - dts: true, // generate dts files - format: ['cjs', 'esm'], // generate cjs and esm files - // sourcemap: env === 'production', - minify: env === 'production', - bundle: env === 'production', - shims: true, - skipNodeModulesBundle: true, - entryPoints: ['src/index.ts'], - watch: env === 'development' ? 'src' : false, - target: 'es2020', - outDir: env === 'production' ? 'dist' : 'lib', - // entry: ['src/**/*.ts'], //include all files under src - entry: ['src/index.ts'], - } -})