optimo 0.0.10 → 0.0.12
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/README.md +11 -2
- package/bin/help.js +3 -0
- package/bin/index.js +14 -0
- package/package.json +2 -2
- package/src/index.js +96 -143
- package/src/magick.js +128 -0
- package/src/util.js +52 -1
package/README.md
CHANGED
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
npx -y optimo public/media # for a directory
|
|
17
17
|
npx -y optimo public/media/banner.png # for a file
|
|
18
18
|
npx -y optimo public/media/banner.png -f jpeg # convert + optimize
|
|
19
|
+
npx -y optimo public/media/banner.png -r 50% # resize + optimize
|
|
20
|
+
npx -y optimo public/media/banner.png -r 100kB # resize to max file size
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
##
|
|
23
|
+
## Highlights
|
|
22
24
|
|
|
25
|
+
- Metadata stripping.
|
|
23
26
|
- Compression-first per format.
|
|
24
|
-
- Metadata stripping (`-strip`) where applicable.
|
|
25
27
|
- Format-specific tuning for stronger size reduction.
|
|
26
28
|
- Safety guard: if optimized output is not smaller, original file is kept.
|
|
29
|
+
- Resizing supports percentage values (`50%`) and max file size targets (`100kB`, `2MB`).
|
|
27
30
|
|
|
28
31
|
## Programmatic API
|
|
29
32
|
|
|
@@ -34,6 +37,12 @@ const optimo = require('optimo')
|
|
|
34
37
|
await optimo.file('/absolute/path/image.jpg', {
|
|
35
38
|
dryRun: false,
|
|
36
39
|
format: 'webp',
|
|
40
|
+
resize: '50%',
|
|
41
|
+
onLogs: console.log
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await optimo.file('/absolute/path/image.jpg', {
|
|
45
|
+
resize: '100kB',
|
|
37
46
|
onLogs: console.log
|
|
38
47
|
})
|
|
39
48
|
|
package/bin/help.js
CHANGED
|
@@ -10,10 +10,13 @@ Usage
|
|
|
10
10
|
Options
|
|
11
11
|
-d, --dry-run Show what would be optimized without making changes
|
|
12
12
|
-f, --format Convert output format (e.g. jpeg, webp, avif)
|
|
13
|
+
-r, --resize Resize by percentage (50%) or target max size (100kB)
|
|
13
14
|
|
|
14
15
|
Examples
|
|
15
16
|
$ optimo image.jpg
|
|
16
17
|
$ optimo image.png --dry-run
|
|
17
18
|
$ optimo image.jpg -d
|
|
18
19
|
$ optimo image.png -f jpeg
|
|
20
|
+
$ optimo image.png -r 50%
|
|
21
|
+
$ optimo image.png -r 100kB
|
|
19
22
|
`)
|
package/bin/index.js
CHANGED
|
@@ -12,11 +12,24 @@ async function main () {
|
|
|
12
12
|
alias: {
|
|
13
13
|
'dry-run': 'd',
|
|
14
14
|
format: 'f',
|
|
15
|
+
resize: 'r',
|
|
15
16
|
silent: 's'
|
|
16
17
|
}
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
const input = argv._[0]
|
|
21
|
+
let resize = argv.resize
|
|
22
|
+
|
|
23
|
+
if (resize !== undefined && resize !== null) {
|
|
24
|
+
const unitToken = argv._[1]
|
|
25
|
+
if (
|
|
26
|
+
unitToken &&
|
|
27
|
+
/^[kmg]?b$/i.test(unitToken) &&
|
|
28
|
+
/^\d*\.?\d+$/.test(String(resize))
|
|
29
|
+
) {
|
|
30
|
+
resize = `${resize}${unitToken}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
20
33
|
|
|
21
34
|
if (!input) {
|
|
22
35
|
console.log(require('./help'))
|
|
@@ -33,6 +46,7 @@ async function main () {
|
|
|
33
46
|
await fn(input, {
|
|
34
47
|
dryRun: argv['dry-run'],
|
|
35
48
|
format: argv.format,
|
|
49
|
+
resize,
|
|
36
50
|
onLogs: logger
|
|
37
51
|
})
|
|
38
52
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "optimo",
|
|
3
3
|
"description": "The no-brainer ImageMagick CLI for optimizing images",
|
|
4
4
|
"homepage": "https://github.com/kikobeats/optimo",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.12",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js"
|
|
8
8
|
},
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"clean": "rm -rf node_modules",
|
|
63
63
|
"contributors": "(git-authors-cli && finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
|
|
64
64
|
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
|
|
65
|
-
"lint": "standard
|
|
65
|
+
"lint": "standard",
|
|
66
66
|
"postrelease": "pnpm release:tags && pnpm release:github && (ci-publish || npm publish --access=public)",
|
|
67
67
|
"pretest": "pnpm lint",
|
|
68
68
|
"release": "pnpm release:version && pnpm release:changelog && pnpm release:commit && pnpm release:tag",
|
package/src/index.js
CHANGED
|
@@ -1,182 +1,135 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { stat, unlink, rename, readdir } = require('node:fs/promises')
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
const path = require('node:path')
|
|
6
6
|
const $ = require('tinyspawn')
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const { magickPath, magickFlags } = require('./magick')
|
|
9
9
|
const { yellow, gray, green } = require('./colors')
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'-sampling-factor',
|
|
19
|
-
'4:2:0',
|
|
20
|
-
'-define',
|
|
21
|
-
'jpeg:optimize-coding=true',
|
|
22
|
-
'-define',
|
|
23
|
-
'jpeg:dct-method=float',
|
|
24
|
-
'-quality',
|
|
25
|
-
'80',
|
|
26
|
-
'-interlace',
|
|
27
|
-
'Plane'
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
/*
|
|
31
|
-
* PNG preset (maximum deflate effort):
|
|
32
|
-
* - Strip metadata payloads.
|
|
33
|
-
* - Use highest compression level with explicit strategy/filter tuning.
|
|
34
|
-
*/
|
|
35
|
-
const MAGICK_PNG_FLAGS = [
|
|
36
|
-
'-strip',
|
|
37
|
-
'-define',
|
|
38
|
-
'png:compression-level=9',
|
|
39
|
-
'-define',
|
|
40
|
-
'png:compression-strategy=1',
|
|
41
|
-
'-define',
|
|
42
|
-
'png:compression-filter=5'
|
|
43
|
-
]
|
|
44
|
-
|
|
45
|
-
/*
|
|
46
|
-
* GIF preset (animation optimization):
|
|
47
|
-
* - Coalesce frames before layer optimization to maximize delta compression.
|
|
48
|
-
* - OptimizePlus is more aggressive for animated GIF size reduction.
|
|
49
|
-
*/
|
|
50
|
-
const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
|
|
51
|
-
|
|
52
|
-
/*
|
|
53
|
-
* WebP preset (compression-first):
|
|
54
|
-
* - Use a strong encoder method and preserve compatibility with lossy output.
|
|
55
|
-
*/
|
|
56
|
-
const MAGICK_WEBP_FLAGS = [
|
|
57
|
-
'-strip',
|
|
58
|
-
'-define',
|
|
59
|
-
'webp:method=6',
|
|
60
|
-
'-define',
|
|
61
|
-
'webp:thread-level=1',
|
|
62
|
-
'-quality',
|
|
63
|
-
'80'
|
|
64
|
-
]
|
|
65
|
-
|
|
66
|
-
/*
|
|
67
|
-
* AVIF preset (compression-first):
|
|
68
|
-
* - Slow encoder speed for stronger compression.
|
|
69
|
-
*/
|
|
70
|
-
const MAGICK_AVIF_FLAGS = [
|
|
71
|
-
'-strip',
|
|
72
|
-
'-define',
|
|
73
|
-
'heic:speed=1',
|
|
74
|
-
'-quality',
|
|
75
|
-
'50'
|
|
76
|
-
]
|
|
77
|
-
|
|
78
|
-
/*
|
|
79
|
-
* HEIC/HEIF preset (compression-first):
|
|
80
|
-
* - Slow encoder speed for stronger compression.
|
|
81
|
-
*/
|
|
82
|
-
const MAGICK_HEIC_FLAGS = [
|
|
83
|
-
'-strip',
|
|
84
|
-
'-define',
|
|
85
|
-
'heic:speed=1',
|
|
86
|
-
'-quality',
|
|
87
|
-
'75'
|
|
88
|
-
]
|
|
89
|
-
|
|
90
|
-
/*
|
|
91
|
-
* JPEG XL preset (compression-first):
|
|
92
|
-
* - Use max effort where supported.
|
|
93
|
-
*/
|
|
94
|
-
const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
|
|
95
|
-
|
|
96
|
-
/*
|
|
97
|
-
* SVG preset:
|
|
98
|
-
* - Keep optimization minimal to avoid destructive transformations.
|
|
99
|
-
*/
|
|
100
|
-
const MAGICK_SVG_FLAGS = ['-strip']
|
|
101
|
-
|
|
102
|
-
/*
|
|
103
|
-
* Generic preset for any other format:
|
|
104
|
-
* - Keep it broadly safe across decoders while still reducing size.
|
|
105
|
-
*/
|
|
106
|
-
const MAGICK_GENERIC_FLAGS = ['-strip']
|
|
107
|
-
|
|
108
|
-
const magickPath = (() => {
|
|
109
|
-
try {
|
|
110
|
-
return execSync('which magick', {
|
|
111
|
-
stdio: ['pipe', 'pipe', 'ignore']
|
|
112
|
-
})
|
|
113
|
-
.toString()
|
|
114
|
-
.trim()
|
|
115
|
-
} catch {
|
|
116
|
-
return false
|
|
117
|
-
}
|
|
118
|
-
})()
|
|
119
|
-
|
|
120
|
-
const percentage = (partial, total) =>
|
|
121
|
-
(((partial - total) / total) * 100).toFixed(1)
|
|
122
|
-
|
|
123
|
-
const normalizeFormat = format => {
|
|
124
|
-
if (!format) return null
|
|
125
|
-
|
|
126
|
-
const normalized = String(format).trim().toLowerCase().replace(/^\./, '')
|
|
127
|
-
if (normalized === 'jpg') return 'jpeg'
|
|
128
|
-
if (normalized === 'tif') return 'tiff'
|
|
129
|
-
|
|
130
|
-
return normalized
|
|
131
|
-
}
|
|
11
|
+
const {
|
|
12
|
+
formatBytes,
|
|
13
|
+
formatLog,
|
|
14
|
+
normalizeFormat,
|
|
15
|
+
parseResize,
|
|
16
|
+
percentage
|
|
17
|
+
} = require('./util')
|
|
132
18
|
|
|
133
19
|
const getOutputPath = (filePath, format) => {
|
|
134
20
|
const normalizedFormat = normalizeFormat(format)
|
|
135
21
|
if (!normalizedFormat) return filePath
|
|
136
|
-
|
|
137
22
|
const parsed = path.parse(filePath)
|
|
138
23
|
return path.join(parsed.dir, `${parsed.name}.${normalizedFormat}`)
|
|
139
24
|
}
|
|
140
25
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
26
|
+
const runMagick = async ({
|
|
27
|
+
filePath,
|
|
28
|
+
optimizedPath,
|
|
29
|
+
flags,
|
|
30
|
+
resizePercentage = null
|
|
31
|
+
}) => {
|
|
32
|
+
const magickArgs = [
|
|
33
|
+
filePath,
|
|
34
|
+
...(resizePercentage ? ['-resize', resizePercentage] : []),
|
|
35
|
+
...flags,
|
|
36
|
+
optimizedPath
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
await $('magick', magickArgs)
|
|
40
|
+
return (await stat(optimizedPath)).size
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const optimizeForMaxSize = async ({
|
|
44
|
+
filePath,
|
|
45
|
+
optimizedPath,
|
|
46
|
+
flags,
|
|
47
|
+
maxSize
|
|
48
|
+
}) => {
|
|
49
|
+
const resultByScale = new Map()
|
|
50
|
+
|
|
51
|
+
const measureScale = async scale => {
|
|
52
|
+
if (resultByScale.has(scale)) return resultByScale.get(scale)
|
|
53
|
+
const resizePercentage = scale === 100 ? null : `${scale}%`
|
|
54
|
+
const size = await runMagick({
|
|
55
|
+
filePath,
|
|
56
|
+
optimizedPath,
|
|
57
|
+
flags,
|
|
58
|
+
resizePercentage
|
|
59
|
+
})
|
|
60
|
+
resultByScale.set(scale, size)
|
|
61
|
+
return size
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fullSize = await measureScale(100)
|
|
65
|
+
if (fullSize <= maxSize) {
|
|
66
|
+
return fullSize
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const minScaleSize = await measureScale(1)
|
|
70
|
+
if (minScaleSize > maxSize) {
|
|
71
|
+
return minScaleSize
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let low = 1
|
|
75
|
+
let high = 100
|
|
76
|
+
let bestScale = 1
|
|
77
|
+
|
|
78
|
+
while (high - low > 1) {
|
|
79
|
+
const mid = Math.floor((low + high) / 2)
|
|
80
|
+
const size = await measureScale(mid)
|
|
81
|
+
|
|
82
|
+
if (size <= maxSize) {
|
|
83
|
+
low = mid
|
|
84
|
+
bestScale = mid
|
|
85
|
+
} else {
|
|
86
|
+
high = mid
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return resultByScale.get(bestScale)
|
|
152
91
|
}
|
|
153
92
|
|
|
154
93
|
const file = async (
|
|
155
94
|
filePath,
|
|
156
|
-
{ onLogs = () => {}, dryRun, format: outputFormat } = {}
|
|
95
|
+
{ onLogs = () => {}, dryRun, format: outputFormat, resize } = {}
|
|
157
96
|
) => {
|
|
158
97
|
if (!magickPath) {
|
|
159
98
|
throw new Error('ImageMagick is not installed')
|
|
160
99
|
}
|
|
161
100
|
const outputPath = getOutputPath(filePath, outputFormat)
|
|
162
|
-
const
|
|
101
|
+
const resizeConfig = parseResize(resize)
|
|
102
|
+
const flags = magickFlags(outputPath)
|
|
163
103
|
|
|
164
104
|
const optimizedPath = `${outputPath}.optimized`
|
|
165
105
|
const isConverting = outputPath !== filePath
|
|
166
106
|
|
|
167
107
|
let originalSize
|
|
108
|
+
let optimizedSize
|
|
109
|
+
|
|
168
110
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
111
|
+
originalSize = (await stat(filePath)).size
|
|
112
|
+
|
|
113
|
+
if (resizeConfig?.mode === 'max-size') {
|
|
114
|
+
optimizedSize = await optimizeForMaxSize({
|
|
115
|
+
filePath,
|
|
116
|
+
optimizedPath,
|
|
117
|
+
flags,
|
|
118
|
+
maxSize: resizeConfig.value
|
|
119
|
+
})
|
|
120
|
+
} else {
|
|
121
|
+
optimizedSize = await runMagick({
|
|
122
|
+
filePath,
|
|
123
|
+
optimizedPath,
|
|
124
|
+
flags,
|
|
125
|
+
resizePercentage: resizeConfig?.value
|
|
126
|
+
})
|
|
127
|
+
}
|
|
173
128
|
} catch {
|
|
174
129
|
onLogs(formatLog('[unsupported]', yellow, filePath))
|
|
175
130
|
return { originalSize: 0, optimizedSize: 0 }
|
|
176
131
|
}
|
|
177
132
|
|
|
178
|
-
const optimizedSize = (await stat(optimizedPath)).size
|
|
179
|
-
|
|
180
133
|
if (!isConverting && optimizedSize >= originalSize) {
|
|
181
134
|
await unlink(optimizedPath)
|
|
182
135
|
onLogs(formatLog('[optimized]', gray, filePath))
|
package/src/magick.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const { execSync } = require('child_process')
|
|
5
|
+
|
|
6
|
+
const magickPath = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return execSync('which magick', {
|
|
9
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
10
|
+
})
|
|
11
|
+
.toString()
|
|
12
|
+
.trim()
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
})()
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* JPEG preset (compression-first):
|
|
20
|
+
* - Favor smaller output via chroma subsampling and optimized Huffman coding.
|
|
21
|
+
* - Progressive scan improves perceived loading on the web.
|
|
22
|
+
*/
|
|
23
|
+
const MAGICK_JPEG_FLAGS = [
|
|
24
|
+
'-strip',
|
|
25
|
+
'-sampling-factor',
|
|
26
|
+
'4:2:0',
|
|
27
|
+
'-define',
|
|
28
|
+
'jpeg:optimize-coding=true',
|
|
29
|
+
'-define',
|
|
30
|
+
'jpeg:dct-method=float',
|
|
31
|
+
'-quality',
|
|
32
|
+
'80',
|
|
33
|
+
'-interlace',
|
|
34
|
+
'Plane'
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* PNG preset (maximum deflate effort):
|
|
39
|
+
* - Strip metadata payloads.
|
|
40
|
+
* - Use highest compression level with explicit strategy/filter tuning.
|
|
41
|
+
*/
|
|
42
|
+
const MAGICK_PNG_FLAGS = [
|
|
43
|
+
'-strip',
|
|
44
|
+
'-define',
|
|
45
|
+
'png:compression-level=9',
|
|
46
|
+
'-define',
|
|
47
|
+
'png:compression-strategy=1',
|
|
48
|
+
'-define',
|
|
49
|
+
'png:compression-filter=5'
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* GIF preset (animation optimization):
|
|
54
|
+
* - Coalesce frames before layer optimization to maximize delta compression.
|
|
55
|
+
* - OptimizePlus is more aggressive for animated GIF size reduction.
|
|
56
|
+
*/
|
|
57
|
+
const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* WebP preset (compression-first):
|
|
61
|
+
* - Use a strong encoder method and preserve compatibility with lossy output.
|
|
62
|
+
*/
|
|
63
|
+
const MAGICK_WEBP_FLAGS = [
|
|
64
|
+
'-strip',
|
|
65
|
+
'-define',
|
|
66
|
+
'webp:method=6',
|
|
67
|
+
'-define',
|
|
68
|
+
'webp:thread-level=1',
|
|
69
|
+
'-quality',
|
|
70
|
+
'80'
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* AVIF preset (compression-first):
|
|
75
|
+
* - Slow encoder speed for stronger compression.
|
|
76
|
+
*/
|
|
77
|
+
const MAGICK_AVIF_FLAGS = [
|
|
78
|
+
'-strip',
|
|
79
|
+
'-define',
|
|
80
|
+
'heic:speed=1',
|
|
81
|
+
'-quality',
|
|
82
|
+
'50'
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* HEIC/HEIF preset (compression-first):
|
|
87
|
+
* - Slow encoder speed for stronger compression.
|
|
88
|
+
*/
|
|
89
|
+
const MAGICK_HEIC_FLAGS = [
|
|
90
|
+
'-strip',
|
|
91
|
+
'-define',
|
|
92
|
+
'heic:speed=1',
|
|
93
|
+
'-quality',
|
|
94
|
+
'75'
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* JPEG XL preset (compression-first):
|
|
99
|
+
* - Use max effort where supported.
|
|
100
|
+
*/
|
|
101
|
+
const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* SVG preset:
|
|
105
|
+
* - Keep optimization minimal to avoid destructive transformations.
|
|
106
|
+
*/
|
|
107
|
+
const MAGICK_SVG_FLAGS = ['-strip']
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
* Generic preset for any other format:
|
|
111
|
+
* - Keep it broadly safe across decoders while still reducing size.
|
|
112
|
+
*/
|
|
113
|
+
const MAGICK_GENERIC_FLAGS = ['-strip']
|
|
114
|
+
|
|
115
|
+
const magickFlags = filePath => {
|
|
116
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
117
|
+
if (ext === '.jpg' || ext === '.jpeg') return MAGICK_JPEG_FLAGS
|
|
118
|
+
if (ext === '.png') return MAGICK_PNG_FLAGS
|
|
119
|
+
if (ext === '.gif') return MAGICK_GIF_FLAGS
|
|
120
|
+
if (ext === '.webp') return MAGICK_WEBP_FLAGS
|
|
121
|
+
if (ext === '.avif') return MAGICK_AVIF_FLAGS
|
|
122
|
+
if (ext === '.heic' || ext === '.heif') return MAGICK_HEIC_FLAGS
|
|
123
|
+
if (ext === '.jxl') return MAGICK_JXL_FLAGS
|
|
124
|
+
if (ext === '.svg') return MAGICK_SVG_FLAGS
|
|
125
|
+
return MAGICK_GENERIC_FLAGS
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { magickPath, magickFlags }
|
package/src/util.js
CHANGED
|
@@ -18,7 +18,58 @@ const formatLog = (plainStatus, colorize, filePath) => {
|
|
|
18
18
|
return `${colorize(paddedPlainStatus)} ${gray(filePath)}`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const percentage = (partial, total) =>
|
|
22
|
+
(((partial - total) / total) * 100).toFixed(1)
|
|
23
|
+
|
|
24
|
+
const normalizeFormat = format => {
|
|
25
|
+
if (!format) return null
|
|
26
|
+
const normalized = String(format).trim().toLowerCase().replace(/^\./, '')
|
|
27
|
+
if (normalized === 'jpg') return 'jpeg'
|
|
28
|
+
if (normalized === 'tif') return 'tiff'
|
|
29
|
+
return normalized
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parseResize = resize => {
|
|
33
|
+
if (resize === undefined || resize === null || resize === '') return null
|
|
34
|
+
|
|
35
|
+
const raw = String(resize).trim()
|
|
36
|
+
const normalized = raw.toLowerCase().replace(/\s+/g, '')
|
|
37
|
+
|
|
38
|
+
const maxSizeMatch = normalized.match(/^(\d*\.?\d+)(b|kb|mb|gb)$/)
|
|
39
|
+
if (maxSizeMatch) {
|
|
40
|
+
const units = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }
|
|
41
|
+
const value = Number(maxSizeMatch[1])
|
|
42
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
'Resize max size must be greater than 0 (e.g. 100kB, 2MB)'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
mode: 'max-size',
|
|
50
|
+
value: Math.floor(value * units[maxSizeMatch[2]])
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const percentage = raw.replace(/%$/, '')
|
|
55
|
+
const value = Number(percentage)
|
|
56
|
+
|
|
57
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
'Resize must be a percentage (e.g. 50 or 50%) or max size (e.g. 100kB)'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
mode: 'percentage',
|
|
65
|
+
value: `${value}%`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
21
69
|
module.exports = {
|
|
22
70
|
formatBytes,
|
|
23
|
-
formatLog
|
|
71
|
+
formatLog,
|
|
72
|
+
normalizeFormat,
|
|
73
|
+
parseResize,
|
|
74
|
+
percentage
|
|
24
75
|
}
|