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

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

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

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

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

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

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

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

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

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

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

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

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

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を利用しています。

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

<root>
├── index.js
├── pgjmin.js
├── pgjminPng.js
├── pgjminGif.js
├── pgjminJpg.js
└── package.json

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

#!/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が完了したら終了します。

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に記述されています。

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に記述されています。

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を直接使って圧縮とリサイズを行っています。

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)
}