imgcap 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commitlintrc ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "@commitlint/config-conventional"
4
+ ]
5
+ }
@@ -0,0 +1,11 @@
1
+ # https://github.com/dependabot/dependabot-core/issues/1736
2
+
3
+ version: 2
4
+ updates:
5
+ # Enable version updates for npm
6
+ - package-ecosystem: 'npm'
7
+ # Look for `package.json` and `lock` files in the `root` directory
8
+ directory: '/'
9
+ # Check the npm registry for updates every day (weekdays)
10
+ schedule:
11
+ interval: 'weekly'
@@ -0,0 +1,85 @@
1
+ name: CD
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+ jobs:
9
+ setup:
10
+ runs-on: ubuntu-latest
11
+ outputs:
12
+ cache-hit: ${{ steps.pnpm-cache.outputs.cache-hit }}
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: lts/*
18
+ - uses: pnpm/action-setup@v4
19
+ with:
20
+ version: latest
21
+ - name: Cache pnpm store
22
+ uses: actions/cache@v3
23
+ id: pnpm-cache
24
+ with:
25
+ path: |
26
+ ~/.pnpm-store
27
+ node_modules
28
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
29
+ restore-keys: |
30
+ ${{ runner.os }}-pnpm-
31
+ linter:
32
+ needs: [setup]
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-node@v4
37
+ with:
38
+ node-version: lts/*
39
+ - uses: pnpm/action-setup@v4
40
+ with:
41
+ version: latest
42
+ - name: Restore dependencies
43
+ if: needs.setup.outputs.cache-hit != 'true'
44
+ run: pnpm install --frozen-lockfile
45
+ - run: pnpm run build
46
+ - run: pnpm run lint
47
+ - run: pnpm run check
48
+
49
+ tests:
50
+ needs: [setup]
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - uses: actions/setup-node@v4
55
+ with:
56
+ node-version: lts/*
57
+ - uses: pnpm/action-setup@v4
58
+ with:
59
+ version: latest
60
+ - name: Restore dependencies
61
+ if: needs.setup.outputs.cache-hit != 'true'
62
+ run: pnpm install --frozen-lockfile
63
+ - run: pnpm run build
64
+ - run: pnpm run test
65
+ release:
66
+ needs: [setup, linter, tests]
67
+ runs-on: ubuntu-latest
68
+ steps:
69
+ - uses: actions/checkout@v4
70
+ with:
71
+ persist-credentials: false
72
+ - uses: actions/setup-node@v4
73
+ with:
74
+ node-version: lts/*
75
+ - uses: pnpm/action-setup@v4
76
+ with:
77
+ version: latest
78
+ - name: Restore dependencies
79
+ if: needs.setup.outputs.cache-hit != 'true'
80
+ run: pnpm install --frozen-lockfile --ignore-scripts
81
+ - run: pnpm run build
82
+ - run: pnpm semantic-release
83
+ env:
84
+ GH_TOKEN: ${{ secrets.IMGCAP_GITHUB_TOKEN }}
85
+ NPM_TOKEN: ${{ secrets.IMGCAP_NPM_TOKEN }}
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [develop]
6
+ pull_request:
7
+ branches: [develop]
8
+
9
+ jobs:
10
+ setup:
11
+ runs-on: ubuntu-latest
12
+ outputs:
13
+ cache-hit: ${{ steps.pnpm-cache.outputs.cache-hit }}
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: lts/*
19
+ - uses: pnpm/action-setup@v4
20
+ with:
21
+ version: latest
22
+ - name: Cache pnpm store
23
+ uses: actions/cache@v3
24
+ id: pnpm-cache
25
+ with:
26
+ path: |
27
+ ~/.pnpm-store
28
+ node_modules
29
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
30
+ restore-keys: |
31
+ ${{ runner.os }}-pnpm-
32
+ linter:
33
+ needs: [setup]
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: actions/setup-node@v4
38
+ with:
39
+ node-version: lts/*
40
+ - uses: pnpm/action-setup@v4
41
+ with:
42
+ version: latest
43
+ - name: Restore dependencies
44
+ if: needs.setup.outputs.cache-hit != 'true'
45
+ run: pnpm install --frozen-lockfile
46
+ - run: pnpm run build
47
+ - run: pnpm run lint
48
+ - run: pnpm run check
49
+
50
+ tests:
51
+ needs: [setup]
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: actions/setup-node@v4
56
+ with:
57
+ node-version: lts/*
58
+ - uses: pnpm/action-setup@v4
59
+ with:
60
+ version: latest
61
+ - name: Restore dependencies
62
+ if: needs.setup.outputs.cache-hit != 'true'
63
+ run: pnpm install --frozen-lockfile
64
+ - run: pnpm run build
65
+ - run: pnpm run test
@@ -0,0 +1 @@
1
+ pnpm commitlint --edit "$1"
@@ -0,0 +1 @@
1
+ pnpm lint && pnpm check
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "printWidth": 120
6
+ }
package/.releaserc ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "branches": [
3
+ "master"
4
+ ],
5
+ "plugins": [
6
+ "@semantic-release/commit-analyzer",
7
+ "@semantic-release/release-notes-generator",
8
+ "@semantic-release/changelog",
9
+ "@semantic-release/github",
10
+ "@semantic-release/npm",
11
+ "@semantic-release/git"
12
+ ]
13
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # 1.0.0 (2025-07-14)
2
+
3
+
4
+ ### Performance Improvements
5
+
6
+ * add comments ([ae0d4ce](https://github.com/molvqingtai/imgcap/commit/ae0d4ce6adba6c3fb5794319a3ea005f1e1929f7))
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 John Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # ImgCap
2
+
3
+ > Automatically compress images to exact file size using binary search algorithm. No more "file too large" errors.
4
+
5
+ ## Why ImgCap?
6
+
7
+ Users often encounter "File too large" errors when uploading images, forcing them to manually compress files using external tools. This creates friction and leads to user dropout. imgcap solves this by automatically compressing images to exact size requirements - no user intervention needed.
8
+
9
+ ```typescript
10
+ // Before: User sees error, leaves frustrated
11
+ ❌ "File too large: Image upload size cannot exceed 2MB"
12
+
13
+ // After: Seamless auto-compression
14
+ ✅ await imgcap(userPhoto, { targetSize: 2 * 1024 * 1024 })
15
+ ```
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pnpm install imgcap
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```typescript
26
+ import imgcap from 'imgcap'
27
+
28
+ // Social media avatar (500KB limit)
29
+ const avatar = await imgcap(file, { targetSize: 500 * 1024 })
30
+
31
+ // With format conversion
32
+ const webp = await imgcap(imageFile, {
33
+ targetSize: 300 * 1024,
34
+ outputType: 'image/webp'
35
+ })
36
+ ```
37
+
38
+ ## API
39
+
40
+ ```typescript
41
+ interface Options {
42
+ targetSize: number // Target file size in bytes
43
+ toleranceSize?: number // Size tolerance (default: -1024)
44
+ outputType?: ImageType // Output format (default: same as input)
45
+ }
46
+
47
+ type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'
48
+ ```
49
+
50
+ **Browser only** - Requires OffscreenCanvas support (modern browsers).
51
+
52
+ ## License
53
+
54
+ MIT © [molvqingtai](https://github.com/molvqingtai)
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "printWidth": 120
6
+ }
@@ -0,0 +1,18 @@
1
+ import globals from 'globals'
2
+ import pluginJs from '@eslint/js'
3
+ import tseslint from 'typescript-eslint'
4
+ import prettierPlugin from 'eslint-plugin-prettier/recommended'
5
+
6
+ export default [
7
+ { files: ['**/*.{js,mjs,cjs,ts}'] },
8
+ { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
9
+ pluginJs.configs.recommended,
10
+ ...tseslint.configs.recommended,
11
+ prettierPlugin,
12
+ {
13
+ ignores: ['**/dist/*']
14
+ },
15
+ {
16
+ rules: {}
17
+ }
18
+ ]
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "__tests__",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "vitest --browser.headless",
8
+ "lint": "eslint --fix --cache",
9
+ "check": "tsc --noEmit",
10
+ "postinstall": "playwright install --with-deps chromium"
11
+ },
12
+ "keywords": [],
13
+ "author": "molvqingtai",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "imgcap": "workspace:*"
17
+ },
18
+ "devDependencies": {
19
+ "vitest": "^3.2.4",
20
+ "@vitest/browser": "^3.2.4",
21
+ "playwright": "^1.49.1",
22
+ "@eslint/js": "^9.31.0",
23
+ "eslint": "^9.31.0",
24
+ "eslint-config-prettier": "^10.1.5",
25
+ "eslint-plugin-prettier": "^5.5.1",
26
+ "globals": "^16.3.0",
27
+ "typescript": "^5.8.3",
28
+ "typescript-eslint": "^8.36.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ /* Basic Options */
4
+ "baseUrl": ".",
5
+ "rootDir": ".",
6
+ /* Strict Type-Checking Options */
7
+ "strict": true /* Enable all strict type-checking options. */,
8
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
9
+ "skipLibCheck": true /* Skip type checking of declaration files. */,
10
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
11
+ "moduleResolution": "Node",
12
+ "isolatedModules": true
13
+ }
14
+ }
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { createTestImageBlob } from '../utils'
3
+ import { imgcap } from 'imgcap'
4
+
5
+ describe('imgcap', () => {
6
+ let testImageBlob: Blob
7
+
8
+ beforeAll(async () => {
9
+ testImageBlob = await createTestImageBlob(200, 200, 'image/png')
10
+ })
11
+
12
+ describe('Input Validation', () => {
13
+ it('should throw error for unsupported image type', async () => {
14
+ const invalidBlob = new Blob(['test'], { type: 'text/plain' })
15
+
16
+ await expect(imgcap(invalidBlob, { targetSize: 10000 })).rejects.toThrow(
17
+ 'Only PNG, JPEG, WebP and AVIF images are supported.'
18
+ )
19
+ })
20
+
21
+ it('should throw error for tolerance size less than 1024', async () => {
22
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 512 })).rejects.toThrow(
23
+ 'Tolerance size must be at least ±1024 bytes.'
24
+ )
25
+ })
26
+
27
+ it('should throw error for negative tolerance size less than -1024', async () => {
28
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -512 })).rejects.toThrow(
29
+ 'Tolerance size must be at least ±1024 bytes.'
30
+ )
31
+ })
32
+
33
+ it('should accept tolerance size of exactly ±1024', async () => {
34
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 1024 })).resolves.toBeDefined()
35
+
36
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -1024 })).resolves.toBeDefined()
37
+ })
38
+
39
+ it('should accept tolerance size greater than ±1024', async () => {
40
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: 2048 })).resolves.toBeDefined()
41
+
42
+ await expect(imgcap(testImageBlob, { targetSize: 10000, toleranceSize: -2048 })).resolves.toBeDefined()
43
+ })
44
+ })
45
+
46
+ describe('Basic Functionality', () => {
47
+ it('should return original blob if already smaller than target size', async () => {
48
+ const smallBlob = await createTestImageBlob(10, 10, 'image/png')
49
+ const result = await imgcap(smallBlob, { targetSize: 100000 })
50
+
51
+ expect(result).toBe(smallBlob)
52
+ expect(result.size).toBe(smallBlob.size)
53
+ })
54
+
55
+ it('should compress image to approximate target size', async () => {
56
+ const targetSize = 5000
57
+ const result = await imgcap(testImageBlob, {
58
+ targetSize,
59
+ toleranceSize: -1024
60
+ })
61
+
62
+ expect(result).toBeInstanceOf(Blob)
63
+ expect(result.size).toBeLessThanOrEqual(targetSize)
64
+ // 允许更大的容差范围,因为压缩算法可能产生更小的文件
65
+ expect(result.size).toBeGreaterThan(500)
66
+ })
67
+
68
+ it('should respect tolerance range', async () => {
69
+ const targetSize = 3000
70
+ const toleranceSize = -1024
71
+ const result = await imgcap(testImageBlob, {
72
+ targetSize,
73
+ toleranceSize
74
+ })
75
+
76
+ const lowerBound = targetSize + Math.min(0, toleranceSize)
77
+ const upperBound = targetSize + Math.max(0, toleranceSize)
78
+
79
+ expect(result.size).toBeLessThanOrEqual(upperBound)
80
+ // 对于负容差,只检查上界,因为压缩可能产生比期望更小的文件
81
+ if (toleranceSize < 0) {
82
+ expect(result.size).toBeGreaterThan(500) // 确保不会过小
83
+ } else {
84
+ expect(result.size).toBeGreaterThanOrEqual(lowerBound)
85
+ }
86
+ })
87
+ })
88
+
89
+ describe('Output Format Control', () => {
90
+ it('should maintain original format when no outputType specified', async () => {
91
+ const jpegBlob = await createTestImageBlob(100, 100, 'image/jpeg')
92
+ const result = await imgcap(jpegBlob, { targetSize: 10000 })
93
+
94
+ expect(result.type).toBe('image/jpeg')
95
+ })
96
+
97
+ it('should convert to specified output format', async () => {
98
+ const result = await imgcap(testImageBlob, {
99
+ targetSize: 10000,
100
+ outputType: 'image/jpeg'
101
+ })
102
+
103
+ expect(result.type).toBe('image/jpeg')
104
+ })
105
+
106
+ it('should handle PNG to WebP conversion', async () => {
107
+ const result = await imgcap(testImageBlob, {
108
+ targetSize: 10000,
109
+ outputType: 'image/webp'
110
+ })
111
+
112
+ expect(result.type).toBe('image/webp')
113
+ })
114
+ })
115
+
116
+ describe('Compression Quality', () => {
117
+ it('should compress large images significantly', async () => {
118
+ const largeBlob = await createTestImageBlob(500, 500, 'image/png')
119
+ const targetSize = 5000
120
+ const result = await imgcap(largeBlob, { targetSize })
121
+
122
+ expect(result.size).toBeLessThan(largeBlob.size)
123
+ expect(result.size).toBeLessThanOrEqual(targetSize)
124
+ })
125
+
126
+ it('should handle very small target sizes', async () => {
127
+ const targetSize = 2000
128
+ const result = await imgcap(testImageBlob, {
129
+ targetSize,
130
+ toleranceSize: -1024
131
+ })
132
+
133
+ expect(result.size).toBeLessThanOrEqual(targetSize)
134
+ expect(result.size).toBeGreaterThan(500) // Should not be too small
135
+ })
136
+
137
+ it('should handle edge case with very large tolerance', async () => {
138
+ const targetSize = 5000
139
+ const toleranceSize = 10000
140
+ const result = await imgcap(testImageBlob, {
141
+ targetSize,
142
+ toleranceSize
143
+ })
144
+
145
+ expect(result.size).toBeLessThanOrEqual(targetSize + toleranceSize)
146
+ })
147
+ })
148
+
149
+ describe('Error Handling', () => {
150
+ it('should handle invalid image data gracefully', async () => {
151
+ const invalidImageBlob = new Blob(['not an image'], { type: 'text/plain' })
152
+
153
+ await expect(imgcap(invalidImageBlob, { targetSize: 10000 })).rejects.toThrow()
154
+ })
155
+
156
+ it('should handle zero target size', async () => {
157
+ await expect(imgcap(testImageBlob, { targetSize: 0 })).rejects.toThrow()
158
+ })
159
+
160
+ it('should handle negative target size', async () => {
161
+ await expect(imgcap(testImageBlob, { targetSize: -1000 })).rejects.toThrow()
162
+ })
163
+ })
164
+
165
+ describe('Format Support', () => {
166
+ it('should handle JPEG input', async () => {
167
+ const jpegBlob = await createTestImageBlob(150, 150, 'image/jpeg')
168
+ const result = await imgcap(jpegBlob, { targetSize: 8000 })
169
+
170
+ expect(result).toBeInstanceOf(Blob)
171
+ expect(result.type).toBe('image/jpeg')
172
+ })
173
+
174
+ it('should handle WebP format', async () => {
175
+ const webpBlob = await createTestImageBlob(150, 150, 'image/webp')
176
+ const result = await imgcap(webpBlob, { targetSize: 8000 })
177
+
178
+ expect(result).toBeInstanceOf(Blob)
179
+ expect(result.type).toBe('image/webp')
180
+ })
181
+ })
182
+
183
+ describe('Performance', () => {
184
+ it('should handle concurrent compressions', async () => {
185
+ const promises = Array.from({ length: 3 }, (_, i) => imgcap(testImageBlob, { targetSize: 4000 + i * 1000 }))
186
+
187
+ const results = await Promise.all(promises)
188
+
189
+ results.forEach((result, i) => {
190
+ expect(result).toBeInstanceOf(Blob)
191
+ expect(result.size).toBeLessThanOrEqual(4000 + i * 1000)
192
+ })
193
+ })
194
+ })
195
+ })
@@ -0,0 +1,18 @@
1
+ import { type ImageType } from 'imgcap'
2
+
3
+ // Helper function to create a test image blob
4
+ export const createTestImageBlob = async (width: number = 100, height: number = 100, type: ImageType = 'image/png') => {
5
+ const canvas = new OffscreenCanvas(width, height)
6
+ const ctx = canvas.getContext('2d')!
7
+ // Draw a simple pattern
8
+ ctx.fillStyle = '#ff0000'
9
+ ctx.fillRect(0, 0, width / 2, height / 2)
10
+ ctx.fillStyle = '#00ff00'
11
+ ctx.fillRect(width / 2, 0, width / 2, height / 2)
12
+ ctx.fillStyle = '#0000ff'
13
+ ctx.fillRect(0, height / 2, width / 2, height / 2)
14
+ ctx.fillStyle = '#ffff00'
15
+ ctx.fillRect(width / 2, height / 2, width / 2, height / 2)
16
+
17
+ return await canvas.convertToBlob({ type })
18
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ browser: {
6
+ enabled: true,
7
+ provider: 'playwright',
8
+ instances: [{ browser: 'chromium' }]
9
+ }
10
+ }
11
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "printWidth": 120
6
+ }
@@ -0,0 +1,35 @@
1
+ //#region src/index.d.ts
2
+ /** Supported image formats */
3
+ type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif';
4
+ /** Compression options */
5
+ interface Options {
6
+ /** Target file size in bytes */
7
+ targetSize: number;
8
+ /** Size tolerance in bytes (default: -1024) */
9
+ toleranceSize?: number;
10
+ /** Output image format (default: same as input) */
11
+ outputType?: ImageType;
12
+ }
13
+ /**
14
+ * Compress an image to exact target file size using binary search algorithm.
15
+ *
16
+ * @param input - Image blob/file to compress
17
+ * @param options - Compression options
18
+ * @returns Promise that resolves to compressed image blob
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * // Basic usage - compress to 500KB
23
+ * const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })
24
+ *
25
+ * // With format conversion
26
+ * const webp = await imgcap(imageFile, {
27
+ * targetSize: 300 * 1024,
28
+ * outputType: 'image/webp'
29
+ * })
30
+ * ```
31
+ */
32
+ declare const imgcap: (input: Blob, options: Options) => Promise<Blob>;
33
+ //#endregion
34
+ export { ImageType, Options, imgcap as default, imgcap };
35
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ const e=async(t,n,r,i,a,o)=>{let s=(r+i)/2,c=Math.round(t.width*s),l=Math.round(t.height*s),u=new OffscreenCanvas(c,l),d=u.getContext(`2d`);d.drawImage(t,0,0,t.width,t.height,0,0,c,l);let f=await u.convertToBlob({type:o,quality:s}),p=f.size,m=n+Math.min(0,a),h=n+Math.max(0,a);return p>=m&&p<=h||(i-r)/i<.001?f:p>n?await e(t,n,r,s,a,o):await e(t,n,s,i,a,o)},t=async(t,n)=>{let{targetSize:r,toleranceSize:i=-1024}=n;if(![`image/jpeg`,`image/png`,`image/webp`,`image/avif`].includes(t.type))throw Error(`Only PNG, JPEG, WebP and AVIF images are supported.`);if(Math.abs(i)<1024)throw Error(`Tolerance size must be at least ±1024 bytes.`);let a=n.outputType||t.type;if(t.size<=r&&t.type===a)return t;let o=await createImageBitmap(t),s=0,c=1;return await e(o,r,s,c,i,a)};var n=t;export{n as default,t as imgcap};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["imageBitmap: ImageBitmap","targetSize: number","low: number","high: number","toleranceSize: number","outputType: ImageType","input: Blob","options: Options"],"sources":["../src/index.ts"],"sourcesContent":["/** Supported image formats */\nexport type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'\n\n/** Compression options */\nexport interface Options {\n /** Target file size in bytes */\n targetSize: number\n /** Size tolerance in bytes (default: -1024) */\n toleranceSize?: number\n /** Output image format (default: same as input) */\n outputType?: ImageType\n}\n\nconst compress = async (\n imageBitmap: ImageBitmap,\n targetSize: number,\n low: number,\n high: number,\n toleranceSize: number,\n outputType: ImageType\n): Promise<Blob> => {\n // Calculate the middle quality value\n const mid = (low + high) / 2\n\n // Calculate the width and height after scaling\n const width = Math.round(imageBitmap.width * mid)\n const height = Math.round(imageBitmap.height * mid)\n\n const offscreenCanvas = new OffscreenCanvas(width, height)\n const offscreenContext = offscreenCanvas.getContext('2d')!\n\n // Draw the scaled image\n offscreenContext.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height, 0, 0, width, height)\n\n const outputBlob = await offscreenCanvas.convertToBlob({ type: outputType, quality: mid })\n\n const currentSize = outputBlob.size\n\n // Check if current size is within tolerance range\n const lowerBound = targetSize + Math.min(0, toleranceSize)\n const upperBound = targetSize + Math.max(0, toleranceSize)\n\n if (currentSize >= lowerBound && currentSize <= upperBound) {\n return outputBlob\n }\n\n // Use relative error\n if ((high - low) / high < 0.001) {\n return outputBlob\n }\n\n if (currentSize > targetSize) {\n return await compress(imageBitmap, targetSize, low, mid, toleranceSize, outputType)\n } else {\n return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)\n }\n}\n\n/**\n * Compress an image to exact target file size using binary search algorithm.\n *\n * @param input - Image blob/file to compress\n * @param options - Compression options\n * @returns Promise that resolves to compressed image blob\n *\n * @example\n * ```typescript\n * // Basic usage - compress to 500KB\n * const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })\n *\n * // With format conversion\n * const webp = await imgcap(imageFile, {\n * targetSize: 300 * 1024,\n * outputType: 'image/webp'\n * })\n * ```\n */\nexport const imgcap = async (input: Blob, options: Options) => {\n const { targetSize, toleranceSize = -1024 } = options\n\n if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif'].includes(input.type)) {\n throw new Error('Only PNG, JPEG, WebP and AVIF images are supported.')\n }\n\n if (Math.abs(toleranceSize) < 1024) {\n throw new Error('Tolerance size must be at least ±1024 bytes.')\n }\n\n const outputType = options.outputType || (input.type as ImageType)\n\n if (input.size <= targetSize && input.type === outputType) {\n return input\n }\n\n const imageBitmap = await createImageBitmap(input)\n\n // Initialize quality range\n const low = 0\n const high = 1\n\n return await compress(imageBitmap, targetSize, low, high, toleranceSize, outputType)\n}\n\nexport default imgcap\n"],"mappings":"AAaA,MAAM,EAAW,MACfA,EACAC,EACAC,EACAC,EACAC,EACAC,IACkB,CAElB,IAAM,GAAO,EAAM,GAAQ,EAGrB,EAAQ,KAAK,MAAM,EAAY,MAAQ,EAAI,CAC3C,EAAS,KAAK,MAAM,EAAY,OAAS,EAAI,CAE7C,EAAkB,IAAI,gBAAgB,EAAO,GAC7C,EAAmB,EAAgB,WAAW,KAAK,CAGzD,EAAiB,UAAU,EAAa,EAAG,EAAG,EAAY,MAAO,EAAY,OAAQ,EAAG,EAAG,EAAO,EAAO,CAEzG,IAAM,EAAa,KAAM,GAAgB,cAAc,CAAE,KAAM,EAAY,QAAS,CAAK,EAAC,CAEpF,EAAc,EAAW,KAGzB,EAAa,EAAa,KAAK,IAAI,EAAG,EAAc,CACpD,EAAa,EAAa,KAAK,IAAI,EAAG,EAAc,CAcxD,OAZE,GAAe,GAAc,GAAe,IAK3C,EAAO,GAAO,EAAO,KACjB,EAGL,EAAc,EACT,KAAM,GAAS,EAAa,EAAY,EAAK,EAAK,EAAe,EAAW,CAE5E,KAAM,GAAS,EAAa,EAAY,EAAK,EAAM,EAAe,EAAW,AAEvF,EAqBY,EAAS,MAAOC,EAAaC,IAAqB,CAC7D,GAAM,CAAE,aAAY,gBAAgB,MAAO,CAAG,EAE9C,IAAK,CAAC,aAAc,YAAa,aAAc,YAAa,EAAC,SAAS,EAAM,KAAK,CAC/E,KAAM,CAAI,MAAM,sDAAA,CAGlB,GAAI,KAAK,IAAI,EAAc,CAAG,KAC5B,KAAM,CAAI,MAAM,+CAAA,CAGlB,IAAM,EAAa,EAAQ,YAAe,EAAM,KAEhD,GAAI,EAAM,MAAQ,GAAc,EAAM,OAAS,EAC7C,OAAO,EAGT,IAAM,EAAc,KAAM,mBAAkB,EAAM,CAG5C,EAAM,EACN,EAAO,EAEb,OAAO,KAAM,GAAS,EAAa,EAAY,EAAK,EAAM,EAAe,EAAW,AACrF,EAED,IAAA,EAAe"}
@@ -0,0 +1,18 @@
1
+ import globals from 'globals'
2
+ import pluginJs from '@eslint/js'
3
+ import tseslint from 'typescript-eslint'
4
+ import prettierPlugin from 'eslint-plugin-prettier/recommended'
5
+
6
+ export default [
7
+ { files: ['**/*.{js,mjs,cjs,ts}'] },
8
+ { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
9
+ pluginJs.configs.recommended,
10
+ ...tseslint.configs.recommended,
11
+ prettierPlugin,
12
+ {
13
+ ignores: ['**/dist/*']
14
+ },
15
+ {
16
+ rules: {}
17
+ }
18
+ ]
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "core",
3
+ "version": "1.0.0",
4
+ "description": "Precisely cap your image size.",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "tsdown src/index.ts --dts --format esm --sourcemap --watch",
9
+ "build": "tsdown src/index.ts --dts --format esm --sourcemap --minify --clean",
10
+ "lint": "eslint --fix --cache",
11
+ "check": "tsc --noEmit"
12
+ },
13
+ "keywords": [],
14
+ "author": "molvqingtai",
15
+ "license": "MIT",
16
+ "devDependencies": {
17
+ "@eslint/js": "^9.31.0",
18
+ "eslint": "^9.31.0",
19
+ "eslint-config-prettier": "^10.1.5",
20
+ "eslint-plugin-prettier": "^5.5.1",
21
+ "globals": "^16.3.0",
22
+ "tsdown": "^0.12.9",
23
+ "typescript": "^5.8.3",
24
+ "typescript-eslint": "^8.36.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }
@@ -0,0 +1,104 @@
1
+ /** Supported image formats */
2
+ export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'
3
+
4
+ /** Compression options */
5
+ export interface Options {
6
+ /** Target file size in bytes */
7
+ targetSize: number
8
+ /** Size tolerance in bytes (default: -1024) */
9
+ toleranceSize?: number
10
+ /** Output image format (default: same as input) */
11
+ outputType?: ImageType
12
+ }
13
+
14
+ const compress = async (
15
+ imageBitmap: ImageBitmap,
16
+ targetSize: number,
17
+ low: number,
18
+ high: number,
19
+ toleranceSize: number,
20
+ outputType: ImageType
21
+ ): Promise<Blob> => {
22
+ // Calculate the middle quality value
23
+ const mid = (low + high) / 2
24
+
25
+ // Calculate the width and height after scaling
26
+ const width = Math.round(imageBitmap.width * mid)
27
+ const height = Math.round(imageBitmap.height * mid)
28
+
29
+ const offscreenCanvas = new OffscreenCanvas(width, height)
30
+ const offscreenContext = offscreenCanvas.getContext('2d')!
31
+
32
+ // Draw the scaled image
33
+ offscreenContext.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height, 0, 0, width, height)
34
+
35
+ const outputBlob = await offscreenCanvas.convertToBlob({ type: outputType, quality: mid })
36
+
37
+ const currentSize = outputBlob.size
38
+
39
+ // Check if current size is within tolerance range
40
+ const lowerBound = targetSize + Math.min(0, toleranceSize)
41
+ const upperBound = targetSize + Math.max(0, toleranceSize)
42
+
43
+ if (currentSize >= lowerBound && currentSize <= upperBound) {
44
+ return outputBlob
45
+ }
46
+
47
+ // Use relative error
48
+ if ((high - low) / high < 0.001) {
49
+ return outputBlob
50
+ }
51
+
52
+ if (currentSize > targetSize) {
53
+ return await compress(imageBitmap, targetSize, low, mid, toleranceSize, outputType)
54
+ } else {
55
+ return await compress(imageBitmap, targetSize, mid, high, toleranceSize, outputType)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Compress an image to exact target file size using binary search algorithm.
61
+ *
62
+ * @param input - Image blob/file to compress
63
+ * @param options - Compression options
64
+ * @returns Promise that resolves to compressed image blob
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * // Basic usage - compress to 500KB
69
+ * const compressed = await imgcap(imageFile, { targetSize: 500 * 1024 })
70
+ *
71
+ * // With format conversion
72
+ * const webp = await imgcap(imageFile, {
73
+ * targetSize: 300 * 1024,
74
+ * outputType: 'image/webp'
75
+ * })
76
+ * ```
77
+ */
78
+ export const imgcap = async (input: Blob, options: Options) => {
79
+ const { targetSize, toleranceSize = -1024 } = options
80
+
81
+ if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif'].includes(input.type)) {
82
+ throw new Error('Only PNG, JPEG, WebP and AVIF images are supported.')
83
+ }
84
+
85
+ if (Math.abs(toleranceSize) < 1024) {
86
+ throw new Error('Tolerance size must be at least ±1024 bytes.')
87
+ }
88
+
89
+ const outputType = options.outputType || (input.type as ImageType)
90
+
91
+ if (input.size <= targetSize && input.type === outputType) {
92
+ return input
93
+ }
94
+
95
+ const imageBitmap = await createImageBitmap(input)
96
+
97
+ // Initialize quality range
98
+ const low = 0
99
+ const high = 1
100
+
101
+ return await compress(imageBitmap, targetSize, low, high, toleranceSize, outputType)
102
+ }
103
+
104
+ export default imgcap
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ /* Basic Options */
4
+ "baseUrl": ".",
5
+ "rootDir": "src",
6
+ "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
7
+ "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
8
+ "lib": ["ESNext", "Dom"] /* Specify library files to be included in the compilation. */,
9
+ "outDir": "dist" /* Redirect output structure to the directory. */,
10
+ "sourceMap": true,
11
+ "declaration": true /* Generates corresponding '.d.ts' file. */,
12
+ /* Strict Type-Checking Options */
13
+ "strict": true /* Enable all strict type-checking options. */,
14
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
15
+ "skipLibCheck": true /* Skip type checking of declaration files. */,
16
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
17
+ "moduleResolution": "Node",
18
+ "isolatedModules": true,
19
+ "paths": {
20
+ "@/*": ["src/*"]
21
+ }
22
+ },
23
+ "include": ["src"]
24
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "imgcap",
3
+ "version": "1.0.0",
4
+ "description": "Precisely cap your image size.",
5
+ "type": "module",
6
+ "main": "core/dist/index.js",
7
+ "scripts": {
8
+ "dev": "pnpm --filter core dev",
9
+ "build": "pnpm --filter core build",
10
+ "lint": "npm-run-all -p lint:*",
11
+ "check": "npm-run-all -p check:*",
12
+ "test": "pnpm --filter __tests__ test",
13
+ "lint:core": "pnpm --filter core lint",
14
+ "lint:tests": "pnpm --filter __tests__ lint",
15
+ "check:core": "pnpm --filter core check",
16
+ "check:tests": "pnpm --filter __tests__ check",
17
+ "prepare": "husky"
18
+ },
19
+ "keywords": [
20
+ "image",
21
+ "compression",
22
+ "compress",
23
+ "resize",
24
+ "optimize",
25
+ "binary-search",
26
+ "file-size",
27
+ "target-size",
28
+ "precise-size",
29
+ "webp",
30
+ "avif",
31
+ "jpeg",
32
+ "png",
33
+ "browser",
34
+ "typescript",
35
+ "upload",
36
+ "quality",
37
+ "canvas",
38
+ "offscreen-canvas"
39
+ ],
40
+ "author": "molvqingtai",
41
+ "license": "MIT",
42
+ "devDependencies": {
43
+ "@commitlint/cli": "^19.8.1",
44
+ "@commitlint/config-conventional": "^19.8.1",
45
+ "@semantic-release/changelog": "^6.0.3",
46
+ "@semantic-release/git": "^10.0.1",
47
+ "husky": "^9.1.7",
48
+ "npm-run-all": "^4.1.5",
49
+ "semantic-release": "^24.2.7"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ packages:
2
+ - 'core'
3
+ - '__tests__'