画像を一括で圧縮するCLIプログラム

最近画像の多い記事を書くことが増えてきたので 画像を一括でリサイズと圧縮をするCLIプログラムを書きました。 とりあえずpngとアニメーションgifとjpgを圧縮するようにしています。

実行にはNode.jsの環境が必要です。 次のコマンドでインストールします。

1
npm install -g git://github.com/MatchaChoco010/pgjmin.git

次のようにして使います。

1
pgjmin -o <outdir> <image-path ...>

拡張子.png``.jpg``.jpeg``.gif以外は無視されます。

入力の画像のパスにはglobパターンが利用可能です。

1
2
3
pgjmin -o out "*.png"
pgjmin -o out "*.{png,gif}"
pgjmin -o out "*.png" "*.jpg"

リサイズの大きさを指定できます。

1
pgjmin --width 600 --height 400 -o out *.gif

widthheightより大きい画像だけリサイズされます。 widthheightより小さい画像はそのままです。 省略するとリサイズされません。

圧縮のパラメータを渡せます。

1
2
3
4
5
6
pgjmin -o out --pngquant-quality 65-80 *.png
pgjmin -o out --pngquant-posterize 4 *.png
pgjmin -o out --mozjpeg-quality 80 *.jpg
pgjmin -o out --gifsicle-optimize 3 *.gif
pgjmin -o out --gifsicle-colors 128 *.gif
pgjmin -o out --mozjpeg-quality 0 --pngquant-posterize 4 --gifsicle--colors 64 --gifsicle-optimize 3 *.png *.gif *.jpg
  • –pngquant-quality

min-maxの書式で指定します。 0(最低)から100(最高)までの間で指定します。 デフォルトでは65-80です。

  • –pngquant-posterize

0から4までの値で指定します。

  • –mozjpeg-quality

0から100の値を指定します。 デフォルトでは80を指定しています。

  • –gifsicle-optimize

1から3の値を設定します。

  • –gifsicle-colors

2から256の値を設定します。


やっていることはjimpでのリサイズとimageminによる圧縮をしているだけです。 圧縮にはimagemin-pngquantgifsicleimagemin-mozjpegを利用しています。

次のようなファイル構成になっています。

1
2
3
4
5
6
7
<root>
├── index.js
├── pgjmin.js
├── pgjminPng.js
├── pgjminGif.js
├── pgjminJpg.js
└── package.json

index.jsではコマンドラインオプションをパースしてpgjminを呼び出しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#!/usr/bin/env node

const program = require('commander')
const fs = require('fs')
const path = require('path')

const pkg = require('./package.json')

const pgjmin = require('./pgjmin.js')

program
.version(pkg.version)
.usage('[options] <file...>')
.option('-o, --out-dir <dir>', 'Output directory')
.option('-w, --width <width>', 'Resize width')
.option('-h, --height <height>', 'Resize height')
.option('--pngquant-quality <min-max>', 'pngquant quality')
.option('--pngquant-posterize <0..4>', 'pngquant posterize')
.option('--mozjpeg-quality <0..100>', 'mozjpeg quality')
.option('--gifsicle-optimize <1..3>', 'gifsicle optimize')
.option('--gifsicle-colors <2..256>', 'gifsicle colors')

program.on('--help', () => {
console.log('')
console.log('Examples:')
console.log('')
console.log(' $ pgjmin -o out/ "*.jpg"')
console.log(' $ pgjmin -o out/ "*.{png,jpg}"')
console.log(' $ pgjmin -o out/ -w 300 "*.gif"')
console.log(' $ pgjmin -o out/ -h 300 "*.jpeg"')
console.log(' $ pgjmin -o out/ -w 300 -h 300 "*.gif" "*.png"')
console.log(
' $ pgjmin -o out/ --pngquant-quality 65-80 --pngquant-posterize 4 "*.png"'
)
console.log(
' $ pgjmin -o out --mozjpeg-quality 0 --pngquant-posterize 4 --gifsicle--colors 64 --gifsicle-optimize 3 *.png *.gif *.jpg'
)
})

program.parse(process.argv)

const patterns = program.args

if (patterns.length < 1) {
console.error('file path is required')
process.exit(1)
}

const outdir = path.join(__dirname, program.outDir)

if (outdir === undefined) {
console.error('output directory is required')
process.exit(1)
}

if (!fs.existsSync(outdir)) {
console.error("output directory doesn't exist")
process.exit(1)
}

if (!fs.statSync(outdir).isDirectory()) {
console.error('given output directory path is not a directory')
process.exit(1)
}

let width
if (program.width) {
width = parseInt(program.width)
if (isNaN(width)) {
console.error('width must be integer')
process.exit(1)
}
}

let height
if (program.height) {
height = parseInt(program.height)
if (isNaN(height)) {
console.error('height must be integer')
process.exit(1)
}
}

const options = {}
options.quantpngQuality = program.pngquantQuality
options.pngquantPosterize = program.pngquantPosterize
options.mozjpegQuality = program.mozjpegQuality
options.gifsicleOptimize = program.gifsicleOptimize
options.gifsicleColors = program.gitsicleColors

pgjmin(patterns, outdir, width, height, options)

pgjminpgjmin.jsに定義されています。 pgjminはglobパターンを展開して拡張子のファイルごとに別の関数に渡しています。 すべてのPromiseが完了したら終了します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const path = require('path')
const util = require('util')
const glob = util.promisify(require('glob'))
const flat = require('array.prototype.flat')

const png = require('./pgjminPng.js')
const gif = require('./pgjminGif.js')
const jpg = require('./pgjminJpg.js')

module.exports = async function(patterns, outdir, width, height, options) {
const paths = flat(await Promise.all(patterns.map(pattern => glob(pattern))))

const pngFiles = paths.filter(p => path.extname(p) === '.png').map(file =>
png(file, outdir, width, height, {
quality: options.pngquantQuality,
posterize: options.pngquantPosterize
})
)

const gifFiles = paths.filter(p => path.extname(p) === '.gif').map(file =>
gif(file, outdir, width, height, {
optimize: options.gifsicleOptimize,
colors: options.gifsicleColors
})
)

const jpgFiles = paths
.filter(p => /\.(jpeg)|(jpg)/.test(path.extname(p)))
.map(file =>
jpg(file, outdir, width, height, {
quality: options.mozjpegQuality
})
)

await Promise.all([...pngFiles, ...gifFiles, ...jpgFiles])

console.log('Complete!')
}

拡張子ごとの関数ではjimpでのリサイズとimageminによる圧縮を行っています。

pngファイル用の関数はpgjminPng.jsに記述されています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const fs = require('fs').promises
const path = require('path')
const Jimp = require('jimp')
const imagemin = require('imagemin')
const imageminPngquant = require('imagemin-pngquant')

module.exports = async function(file, outdir, width, height, options = {}) {
const { quality = '65-80', posterize = 0 } = options

const outpath = path.join(outdir, path.basename(file))

const img = await Jimp.read(file)

if (
width !== undefined &&
width < img.bitmap.width &&
height !== undefined &&
height < img.bitmap.height
) {
img.scaleToFit(width, height)
} else if (width !== undefined && width < img.bitmap.width) {
img.resize(width, Jimp.AUTO)
} else if (height !== undefined && height < img.bitmap.height) {
img.resize(Jimp.AUTO, height)
}

const resizedBuffer = await img.getBufferAsync(Jimp.MIME_PNG)

const optimizedBuffer = await imagemin.buffer(resizedBuffer, {
plugins: [imageminPngquant({ quality, posterize })]
})

await fs.writeFile(outpath, optimizedBuffer)
}

jpgファイル用の関数はpgjminJpg.jsに記述されています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const fs = require('fs').promises
const path = require('path')
const Jimp = require('jimp')
const imagemin = require('imagemin')
const imageminMozjpeg = require('imagemin-mozjpeg')

module.exports = async function(file, outdir, width, height, options = {}) {
const { quality } = options

const outpath = path.join(outdir, path.basename(file))

const img = await Jimp.read(file)

if (
width !== undefined &&
width < img.bitmap.width &&
height !== undefined &&
height < img.bitmap.height
) {
img.scaleToFit(width, height)
} else if (width !== undefined && width < img.bitmap.width) {
img.resize(width, Jimp.AUTO)
} else if (height !== undefined && height < img.bitmap.height) {
img.resize(Jimp.AUTO, height)
}

const resizedBuffer = await img.getBufferAsync(Jimp.MIME_JPEG)

const optimizedBuffer = await imagemin.buffer(resizedBuffer, {
plugins: [imageminMozjpeg({ quality })]
})

await fs.writeFile(outpath, optimizedBuffer)
}

gifファイル用の関数はpgjminJpg.jsに記述されています。 jimpがgifファイルに対応していないためimagemin-gifsicleを使わずに gifsicleを直接使って圧縮とリサイズを行っています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const path = require('path')
const util = require('util')
const execFile = util.promisify(require('child_process').execFile)
const gifsicle = require('gifsicle')

module.exports = async function(file, outdir, width, height, options = {}) {
const { optimize = 3, colors } = options

const outpath = path.join(outdir, path.basename(file))

const args = []
args.push('--output', outpath)

args.push(`--optimize=${optimize}`)

if (colors) {
args.push(`--colors=${colors}`)
}

if (width !== undefined && height !== undefined) {
args.push(`--resize-fit=${width}x${height}`)
} else if (width !== undefined) {
args.push(`--resize-fit-width=${width}`)
} else if (height !== undefined) {
args.push(`--resize-fit-height=${height}`)
}

args.push(file)

await execFile(gifsicle, args)
}