KDP(電子出版)のメモ 急急如律令

Amazon Kindleダイレクト・パブリッシングでの電子出版や電子書籍の作成販売について、文章やイラストの作成や編集方法について書いています。

画像処理をJimpにするかsharpにするか

AozoraEpub3JSで表紙画像を入れることはできたが、画像の編集処理で少し詰まっている。面倒なので後から画像編集機能をつけることにする。

Jimpはブラウザでも動くが、sharpは高速に処理できる。 以前にJSに変換してと言ったら、Javaのimageio部分が残っていたので動かなかった。

javaの場合はinputstreamを渡してthis.zosに書き込んでいたが、js bufferを渡してbufferに返す。

jimp-dev.github.io

You:

JSに書き換えて public class ImageUtils { static public void writeImage(InputStream is, BufferedImage srcImage, ZipArchiveOutputStream zos, ImageInfo imageInfo, float jpegQuality, LookupOp gammaOp, int maxImagePixels, int maxImageW, int maxImageH, int dispW, int dispH, int autoMarginLimitH, int autoMarginLimitV, int autoMarginWhiteLevel, float autoMarginPadding, int autoMarginNombre, float nombreSize) { try { String ext = imageInfo.getExt(); int imgW = imageInfo.getWidth(); int imgH = imageInfo.getHeight(); int w = imgW; int h = imgH; imageInfo.setOutWidth(imgW); imageInfo.setOutHeight(imgH); //余白チェック時に読み込んだ画像のバッファ byte imgBuf = null; //回転とコントラスト調整なら読み込んでおく if (srcImage == null && (imageInfo.rotateAngle != 0 || gammaOp != null)) srcImage = readImage(ext, is); int margin = null; if (autoMarginLimitH > 0 || autoMarginLimitV > 0) { int startPixel = (int)(w*0.01); //1% int ignoreEdge = (int)(w*0.03); //3% int dustSize = (int)(w*0.01); //1% //画像がなければ読み込み 変更なしの時にそのまま出力できるように一旦バッファに読み込む if (srcImage == null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); //IOUtils.copy(is, baos); is.transferTo(baos); imgBuf = baos.toByteArray(); try (ByteArrayInputStream bais = new ByteArrayInputStream(imgBuf)) { srcImage = readImage(ext, bais); } } margin = getPlainMargin(srcImage, autoMarginLimitH/100f, autoMarginLimitV/100f, autoMarginWhiteLevel/100f, autoMarginPadding/100f, startPixel, ignoreEdge, dustSize, autoMarginNombre, nombreSize); if (margin[0]==0 && margin[1]==0 && margin[2]==0 && margin[3]==0) margin = null; if (margin != null) { //元画像が幅か高さかチェック int mw = w-margin[0]-margin[2]; int mh = h-margin[1]-margin[3]; double dWH = dispW/(double)dispH; double mWH = mw/(double)mh; //縦横比で画面の横か縦に合わせる方向が変わらないようにマージンを調整する if (w/(double)h < dWH) { //元が縦 if (mWH > dWH && mw > dispW) { //余白除去で横にはみ出す mh = (int)(mw/dWH); margin[3] = h-margin[1]-mh;//下マージンを伸ばす if (margin[3] < 0) { margin[3] = 0; margin[1] = h-mh; } } } else { //元が横 if (mWH < dWH && mh > dispH) { //余白除去で縦にはみ出す mw = (int)(mh*dWH); double mLR = margin[0]+margin[2]; margin[0] = (int)*1; //最大画素数指定 if (maxImageW > 0) scale = Math.min(scale, (double)maxImageW/w); //最大幅指定 if (maxImageH > 0) scale = Math.min(scale, (double)maxImageH/h); //最大高さ指定 if (scale >= 1 && (gammaOp == null || srcImage.getType() == BufferedImage.TYPE_INT_RGB)) { if (srcImage == null) { //変更なしならそのままファイル出力 //IOUtils.copy(is, zos); is.transferTo(zos); } else { if (margin == null && imgBuf != null && imageInfo.rotateAngle==0) { //余白除去が無く画像も編集されていなければバッファからそのまま出力 try (ByteArrayInputStream bais = new ByteArrayInputStream(imgBuf)) { bais.transferTo(zos); } } else { //編集済の画像なら同じ画像形式で書き出し 余白があれば切り取る if (imageInfo.rotateAngle != 0) { BufferedImage outImage = new BufferedImage(h, w, srcImage.getType()); Graphics2D g = outImage.createGraphics(); try { g.setColor(Color.WHITE); g.fillRect(0, 0, h, w); g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); int x = 0; int y = 0; AffineTransform at; if (imageInfo.rotateAngle == 90) { at = AffineTransform.getQuadrantRotateInstance(1, 0, 0); at.translate(0, -imgH); if (margin != null) { x = -margin[3]; y = -margin[0]; } } else { at = AffineTransform.getQuadrantRotateInstance(-1, 0, 0); at.translate(-imgW, 0); if (margin != null) { x = -margin[1]; y = -margin[2]; } } AffineTransformOp ato = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); g.drawImage(srcImage, ato, x, y); } finally { g.dispose(); } srcImage = outImage;//入れ替え } else if (margin != null) srcImage = srcImage.getSubimage(margin[0], margin[1], srcImage.getWidth()-margin[2]-margin[0], srcImage.getHeight()-margin[3]-margin[1]); if (gammaOp != null) { BufferedImage filterdImage = new BufferedImage(srcImage.getWidth(), srcImage.getHeight(), BufferedImage.TYPE_INT_RGB); srcImage = gammaOp.filter(srcImage, filterdImage); srcImage = filterdImage; } _writeImage(zos, srcImage, ext, jpegQuality); imageInfo.setOutWidth(srcImage.getWidth()); imageInfo.setOutHeight(srcImage.getHeight()); if (imageInfo.rotateAngle != 0) LogAppender.println("画像回転"+": "+imageInfo.getOutFileName()+" ("+h+","+w+")"); } } } else { //縮小 int scaledW = (int)(w*scale+0.5); int scaledH = (int)(h*scale+0.5); if (imageInfo.rotateAngle != 0) { scaledW = (int)(h*scale+0.5); scaledH = (int)(w*scale+0.5); } //画像がなければ読み込み if (srcImage == null) srcImage = readImage(ext, is); int imageType = srcImage.getType(); BufferedImage outImage; ColorModel colorModel; WritableRaster raster; switch (gammaOp==null?imageType:BufferedImage.TYPE_INT_RGB) { case BufferedImage.TYPE_BYTE_BINARY: colorModel = srcImage.getColorModel(); colorModel = getGray16ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_INDEXED: colorModel = srcImage.getColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break; /*case BufferedImage.TYPE_BYTE_GRAY: //PngEncoderのGRAYが薄くなるのでindexにする colorModel = srcImage.getColorModel(); if (colorModel.getPixelSize() <= 4) colorModel = getGray16ColorModel(); else colorModel = getGray256ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break;*/ case BufferedImage.TYPE_BYTE_GRAY: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_BYTE_GRAY); break; case BufferedImage.TYPE_USHORT_GRAY: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_USHORT_GRAY); break; default: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_INT_RGB); } Graphics2D g = outImage.createGraphics(); try { if (imageType == BufferedImage.TYPE_BYTE_BINARY && imageType == BufferedImage.TYPE_BYTE_INDEXED && imageType == BufferedImage.TYPE_INT_ARGB) { g.setColor(Color.WHITE); g.fillRect(0, 0, scaledW, scaledH); } g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); AffineTransform at = AffineTransform.getScaleInstance(scale, scale); int x = 0; int y = 0; if (imageInfo.rotateAngle == 0) { if (margin != null) { x = (int)(-margin[0]*scale+0.5); y = (int)(-margin[1]*scale+0.5); } } else if (imageInfo.rotateAngle == 90) { at.rotate(Math.toRadians(imageInfo.rotateAngle), 0, 0); at.translate(0, -imgH); if (margin != null) { x = (int)(-margin[3]*scale+0.5); y = (int)(-margin[0]*scale+0.5); } } else { at.quadrantRotate(-1, 0, 0); at.translate(-imgW, 0); if (margin != null) { x = (int)(-margin[1]*scale+0.5); y = (int)(-margin[2]*scale+0.5); } } AffineTransformOp ato = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); g.drawImage(srcImage, ato, x, y); } finally { g.dispose(); } //ImageIO.write(outImage, imageInfo.getExt(), zos); //コントラスト調整 if (gammaOp != null) { BufferedImage filterdImage = new BufferedImage(outImage.getWidth(), outImage.getHeight(), BufferedImage.TYPE_INT_RGB); outImage = gammaOp.filter(outImage, filterdImage); outImage = filterdImage; filterdImage = null; //インデックス化 switch (imageType) { case BufferedImage.TYPE_BYTE_BINARY: colorModel = srcImage.getColorModel(); colorModel = getGray16ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); filterdImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_INDEXED: colorModel = srcImage.getColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); filterdImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_GRAY: filterdImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_BYTE_GRAY); break; case BufferedImage.TYPE_USHORT_GRAY: filterdImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_USHORT_GRAY); break; } if (filterdImage != null) { g = filterdImage.createGraphics(); try { g.drawImage(outImage, 0, 0, null); } finally { g.dispose(); } outImage = filterdImage; } } _writeImage(zos, outImage, ext, jpegQuality); imageInfo.setOutWidth(outImage.getWidth()); imageInfo.setOutHeight(outImage.getHeight()); if (scale < 1) { LogAppender.append("画像縮小"); if (imageInfo.rotateAngle!=0) LogAppender.append("回転"); LogAppender.println(": "+imageInfo.getOutFileName()+" ("+w+","+h+")→("+scaledW+","+scaledH+")"); } zos.flush(); } } catch (Exception e) { LogAppender.println("画像読み込みエラー: "+imageInfo.getOutFileName()); e.printStackTrace(); } } }

ChatGPT:

この Java の ImageUtils.writeImage()

  • 画像読み込み

  • 余白検出

  • 回転

  • ガンマ補正

  • 縮小

  • JPEG/PNG書き出し

  • Zip出力

1メソッドで全部やる巨大処理なので、
Node.js では設計を少し変えると移植が安定します。

特に JSZip
OutputStream に書けないため

Buffer を返す関数に変更する必要があります。

さらに画像処理は通常
sharp を使うのが最も近い挙動になります。


Node.js版 ImageUtils.writeImage(移植版)

JavaScript

import sharp from "sharp";  
  
export default class ImageUtils {  
  
  static async writeImage(  
    is,  
    srcImage,  
    imageInfo,  
    jpegQuality,  
    gammaOp,  
    maxImagePixels,  
    maxImageW,  
    maxImageH,  
    dispW,  
    dispH,  
    autoMarginLimitH,  
    autoMarginLimitV,  
    autoMarginWhiteLevel,  
    autoMarginPadding,  
    autoMarginNombre,  
    nombreSize  
  ) {  
    try {  
  
      const ext \= imageInfo.getExt();  
      let imgBuffer;  
  
      if (srcImage) {  
        imgBuffer \= await srcImage.toBuffer();  
      } else {  
        imgBuffer \= Buffer.isBuffer(is) ? is : await streamToBuffer(is);  
      }  
  
      let image \= sharp(imgBuffer);  
      const meta \= await image.metadata();  
  
      let w \= meta.width;  
      let h \= meta.height;  
  
      imageInfo.setOutWidth(w);  
      imageInfo.setOutHeight(h);  
  
      //------------------------------------------------  
      // 回転  
      //------------------------------------------------  
      if (imageInfo.rotateAngle) {  
        image \= image.rotate(imageInfo.rotateAngle);  
      }  
  
      //------------------------------------------------  
      // 最大画素制限  
      //------------------------------------------------  
      let scale \= 1;  
  
      if (maxImagePixels \>= 10000) {  
        scale \= Math.sqrt(maxImagePixels / (w \* h));  
      }  
  
      if (maxImageW \> 0) scale \= Math.min(scale, maxImageW / w);  
      if (maxImageH \> 0) scale \= Math.min(scale, maxImageH / h);  
  
      if (scale < 1) {  
  
        const scaledW \= Math.round(w \* scale);  
        const scaledH \= Math.round(h \* scale);  
  
        image \= image.resize(scaledW, scaledH, {  
          kernel: sharp.kernel.lanczos3  
        });  
  
        LogAppender.println(  
          \`画像縮小: ${imageInfo.getOutFileName()} (${w},${h})→(${scaledW},${scaledH})\`  
        );  
  
        w \= scaledW;  
        h \= scaledH;  
      }  
  
      //------------------------------------------------  
      // ガンマ補正  
      //------------------------------------------------  
      if (gammaOp) {  
        image \= image.gamma();  
      }  
  
      //------------------------------------------------  
      // 出力  
      //------------------------------------------------  
      let output;  
  
      if (ext.startsWith("jp")) {  
        output \= await image.jpeg({  
          quality: Math.round(jpegQuality \* 100)  
        }).toBuffer();  
      } else if (ext \=== "png") {  
        output \= await image.png().toBuffer();  
      } else {  
        output \= await image.toBuffer();  
      }  
  
      imageInfo.setOutWidth(w);  
      imageInfo.setOutHeight(h);  
  
      return output;  
  
    } catch (e) {  
      LogAppender.println("画像読み込みエラー: " + imageInfo.getOutFileName());  
      console.error(e);  
      return null;  
    }  
  }  
}  
  
// stream → buffer  
async function streamToBuffer(stream) {  
  const chunks \= \[\];  
  for await (const chunk of stream) chunks.push(chunk);  
  return Buffer.concat(chunks);  
}

Java版との対応

Java Node
BufferedImage sharp
AffineTransform sharp.rotate()
Graphics2D.drawImage() sharp.resize()
gammaOp.filter() sharp.gamma()
_writeImage(zos, ...) return Buffer
is.transferTo(zos) zip.file(path, buffer)

重要(AozoraEpub3移植で一番難しい部分)

Java版にはまだ次の処理があります:

  • getPlainMargin()

  • autoMarginNombre

  • 灰色インデックス画像生成

  • TYPE_BYTE_BINARY / TYPE_BYTE_INDEXED

これらは Java2D特有の処理なので
Nodeでは 別アルゴリズムに書き直す必要があります。


実はここが最大の移植ポイント

AozoraEpub3の画像処理は

ImageUtils.java  
約1700行

あります。

しかし Node版は sharp を使うと約200行で同等になります。


もし希望があれば次に AozoraEpub3完全互換の Node版 ImageUtils

  • 余白自動除去 (getPlainMargin)

  • ノンブル削除

  • 回転

  • 最大画素

  • Kindle最適化

まで含めた **完全移植版(約300行)**を書きます。
これが AozoraEpub3 JS移植の一番重要な部分です。

You:

ブラウザで動作させるバージョンも作ろうと思うとjimpのほうがいいのか

ChatGPT:

結論から言うと、ブラウザ対応まで考えるなら
Jimp の方が安全
です。👍

ただし性能や機能には差があるので、用途によって選ぶのがベストです。


ライブラリ比較

Jimp Sharp
Node.js
ブラウザ
ネイティブ依存 ✔ (libvips)
速度 遅い 非常に速い
メモリ効率 普通 良い
画像フォーマット 基本のみ 多い
移植性 高い 低い

AozoraEpub3移植の場合

AozoraEpub3の画像処理は

  • 回転

  • リサイズ

  • 余白削除

  • ガンマ補正

程度なので Jimpでも十分可能です。


ブラウザ版を作る場合の構成(おすすめ)

Node版

高速処理

sharp  
JSZip

Browser版

Jimp  
JSZip

JSZipは両方で動きます
→ JSZip


もう一つの選択肢(実は一番良い)

ImageUtilsの内部だけ切り替える

ImageUtils.node.js   → sharp  
ImageUtils.browser.js → jimp

そして

JavaScript

if (typeof window \=== "undefined") {  
  ImageUtils \= require("./ImageUtils.node.js");  
} else {  
  ImageUtils \= require("./ImageUtils.browser.js");  
}

こうすると

  • CLI

  • Web版

両方作れます。


重要(実はもっと良いライブラリ)

最近は Jimpよりこれが人気です

Pica

理由

  • ブラウザ最速レベル

  • 高品質リサイズ

  • WebWorker対応

ただし

  • 回転

  • PNG/JPEGエンコード

は別処理が必要です。


個人的なおすすめ(AozoraEpub3移植)

一番実用的なのはこの構成👇

Node CLI

sharp  
JSZip

Browser

canvas  
JSZip

Canvas APIはブラウザ標準なので
依存ゼロで画像処理できます。


もし希望があれば

AozoraEpub3を 完全Web版にする場合の最強構成を説明できます。

実はこれです👇

JSZip  
Canvas  
WebWorker  
OffscreenCanvas

これを使うと

ブラウザでEPUB生成がネイティブ並み速度になります。

(AozoraEpub3JSにはかなり向いている構成です)

You:

JSに書き換えて、Jimpを使ってzosではなくbufferをreturnするようにして /** 大きすぎる画像は縮小して出力 * @param is 画像の入力ストリーム srcImageがあれば利用しないのでnull * @param srcImage 読み込み済の場合は画像をこちらに設定 nullならisから読み込む * @param zos 出力先Zipストリーム * @param imageInfo 画像情報 * @param jpegQuality jpeg画質 (低画質 0.0-1.0 高画質) * @param maxImagePixels 縮小する画素数 * @param maxImageW 縮小する画像幅 * @param maxImageH 縮小する画像高さ * @param dispW 画面幅 余白除去後の縦横比補正用 * @param dispH 画面高さ 余白除去後の縦横比補正用 * @param autoMarginLimitH 余白除去 最大% * @param autoMarginLimitV 余白除去 最大% * @param autoMarginWhiteLevel 白画素として判別する白さ 100が白 * @param autoMarginPadding 余白除去後に追加するマージン */ static public void writeImage(InputStream is, BufferedImage srcImage, ZipArchiveOutputStream zos, ImageInfo imageInfo, float jpegQuality, LookupOp gammaOp, int maxImagePixels, int maxImageW, int maxImageH, int dispW, int dispH, int autoMarginLimitH, int autoMarginLimitV, int autoMarginWhiteLevel, float autoMarginPadding, int autoMarginNombre, float nombreSize) { try { String ext = imageInfo.getExt(); int imgW = imageInfo.getWidth(); int imgH = imageInfo.getHeight(); int w = imgW; int h = imgH; imageInfo.setOutWidth(imgW); imageInfo.setOutHeight(imgH); //余白チェック時に読み込んだ画像のバッファ byte imgBuf = null; //回転とコントラスト調整なら読み込んでおく if (srcImage == null && (imageInfo.rotateAngle != 0 || gammaOp != null)) srcImage = readImage(ext, is); int margin = null; if (autoMarginLimitH > 0 || autoMarginLimitV > 0) { int startPixel = (int)(w*0.01); //1% int ignoreEdge = (int)(w*0.03); //3% int dustSize = (int)(w*0.01); //1% //画像がなければ読み込み 変更なしの時にそのまま出力できるように一旦バッファに読み込む if (srcImage == null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); //IOUtils.copy(is, baos); is.transferTo(baos); imgBuf = baos.toByteArray(); try (ByteArrayInputStream bais = new ByteArrayInputStream(imgBuf)) { srcImage = readImage(ext, bais); } } margin = getPlainMargin(srcImage, autoMarginLimitH/100f, autoMarginLimitV/100f, autoMarginWhiteLevel/100f, autoMarginPadding/100f, startPixel, ignoreEdge, dustSize, autoMarginNombre, nombreSize); if (margin[0]==0 && margin[1]==0 && margin[2]==0 && margin[3]==0) margin = null; if (margin != null) { //元画像が幅か高さかチェック int mw = w-margin[0]-margin[2]; int mh = h-margin[1]-margin[3]; double dWH = dispW/(double)dispH; double mWH = mw/(double)mh; //縦横比で画面の横か縦に合わせる方向が変わらないようにマージンを調整する if (w/(double)h < dWH) { //元が縦 if (mWH > dWH && mw > dispW) { //余白除去で横にはみ出す mh = (int)(mw/dWH); margin[3] = h-margin[1]-mh;//下マージンを伸ばす if (margin[3] < 0) { margin[3] = 0; margin[1] = h-mh; } } } else { //元が横 if (mWH < dWH && mh > dispH) { //余白除去で縦にはみ出す mw = (int)(mh*dWH); double mLR = margin[0]+margin[2]; margin[0] = (int)*2; //最大画素数指定 if (maxImageW > 0) scale = Math.min(scale, (double)maxImageW/w); //最大幅指定 if (maxImageH > 0) scale = Math.min(scale, (double)maxImageH/h); //最大高さ指定 if (scale >= 1 && (gammaOp == null || srcImage.getType() == BufferedImage.TYPE_INT_RGB)) { if (srcImage == null) { //変更なしならそのままファイル出力 //IOUtils.copy(is, zos); is.transferTo(zos); } else { if (margin == null && imgBuf != null && imageInfo.rotateAngle==0) { //余白除去が無く画像も編集されていなければバッファからそのまま出力 try (ByteArrayInputStream bais = new ByteArrayInputStream(imgBuf)) { bais.transferTo(zos); } } else { //編集済の画像なら同じ画像形式で書き出し 余白があれば切り取る if (imageInfo.rotateAngle != 0) { BufferedImage outImage = new BufferedImage(h, w, srcImage.getType()); Graphics2D g = outImage.createGraphics(); try { g.setColor(Color.WHITE); g.fillRect(0, 0, h, w); g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); int x = 0; int y = 0; AffineTransform at; if (imageInfo.rotateAngle == 90) { at = AffineTransform.getQuadrantRotateInstance(1, 0, 0); at.translate(0, -imgH); if (margin != null) { x = -margin[3]; y = -margin[0]; } } else { at = AffineTransform.getQuadrantRotateInstance(-1, 0, 0); at.translate(-imgW, 0); if (margin != null) { x = -margin[1]; y = -margin[2]; } } AffineTransformOp ato = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); g.drawImage(srcImage, ato, x, y); } finally { g.dispose(); } srcImage = outImage;//入れ替え } else if (margin != null) srcImage = srcImage.getSubimage(margin[0], margin[1], srcImage.getWidth()-margin[2]-margin[0], srcImage.getHeight()-margin[3]-margin[1]); if (gammaOp != null) { BufferedImage filterdImage = new BufferedImage(srcImage.getWidth(), srcImage.getHeight(), BufferedImage.TYPE_INT_RGB); srcImage = gammaOp.filter(srcImage, filterdImage); srcImage = filterdImage; } _writeImage(zos, srcImage, ext, jpegQuality); imageInfo.setOutWidth(srcImage.getWidth()); imageInfo.setOutHeight(srcImage.getHeight()); if (imageInfo.rotateAngle != 0) LogAppender.println("画像回転"+": "+imageInfo.getOutFileName()+" ("+h+","+w+")"); } } } else { //縮小 int scaledW = (int)(w*scale+0.5); int scaledH = (int)(h*scale+0.5); if (imageInfo.rotateAngle != 0) { scaledW = (int)(h*scale+0.5); scaledH = (int)(w*scale+0.5); } //画像がなければ読み込み if (srcImage == null) srcImage = readImage(ext, is); int imageType = srcImage.getType(); BufferedImage outImage; ColorModel colorModel; WritableRaster raster; switch (gammaOp==null?imageType:BufferedImage.TYPE_INT_RGB) { case BufferedImage.TYPE_BYTE_BINARY: colorModel = srcImage.getColorModel(); colorModel = getGray16ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_INDEXED: colorModel = srcImage.getColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break; /*case BufferedImage.TYPE_BYTE_GRAY: //PngEncoderのGRAYが薄くなるのでindexにする colorModel = srcImage.getColorModel(); if (colorModel.getPixelSize() <= 4) colorModel = getGray16ColorModel(); else colorModel = getGray256ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); outImage = new BufferedImage(colorModel, raster, true, null); break;*/ case BufferedImage.TYPE_BYTE_GRAY: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_BYTE_GRAY); break; case BufferedImage.TYPE_USHORT_GRAY: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_USHORT_GRAY); break; default: outImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_INT_RGB); } Graphics2D g = outImage.createGraphics(); try { if (imageType == BufferedImage.TYPE_BYTE_BINARY && imageType == BufferedImage.TYPE_BYTE_INDEXED && imageType == BufferedImage.TYPE_INT_ARGB) { g.setColor(Color.WHITE); g.fillRect(0, 0, scaledW, scaledH); } g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); AffineTransform at = AffineTransform.getScaleInstance(scale, scale); int x = 0; int y = 0; if (imageInfo.rotateAngle == 0) { if (margin != null) { x = (int)(-margin[0]*scale+0.5); y = (int)(-margin[1]*scale+0.5); } } else if (imageInfo.rotateAngle == 90) { at.rotate(Math.toRadians(imageInfo.rotateAngle), 0, 0); at.translate(0, -imgH); if (margin != null) { x = (int)(-margin[3]*scale+0.5); y = (int)(-margin[0]*scale+0.5); } } else { at.quadrantRotate(-1, 0, 0); at.translate(-imgW, 0); if (margin != null) { x = (int)(-margin[1]*scale+0.5); y = (int)(-margin[2]*scale+0.5); } } AffineTransformOp ato = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); g.drawImage(srcImage, ato, x, y); } finally { g.dispose(); } //ImageIO.write(outImage, imageInfo.getExt(), zos); //コントラスト調整 if (gammaOp != null) { BufferedImage filterdImage = new BufferedImage(outImage.getWidth(), outImage.getHeight(), BufferedImage.TYPE_INT_RGB); outImage = gammaOp.filter(outImage, filterdImage); outImage = filterdImage; filterdImage = null; //インデックス化 switch (imageType) { case BufferedImage.TYPE_BYTE_BINARY: colorModel = srcImage.getColorModel(); colorModel = getGray16ColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); filterdImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_INDEXED: colorModel = srcImage.getColorModel(); raster = colorModel.createCompatibleWritableRaster(scaledW, scaledH); filterdImage = new BufferedImage(colorModel, raster, true, null); break; case BufferedImage.TYPE_BYTE_GRAY: filterdImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_BYTE_GRAY); break; case BufferedImage.TYPE_USHORT_GRAY: filterdImage = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_USHORT_GRAY); break; } if (filterdImage != null) { g = filterdImage.createGraphics(); try { g.drawImage(outImage, 0, 0, null); } finally { g.dispose(); } outImage = filterdImage; } } _writeImage(zos, outImage, ext, jpegQuality); imageInfo.setOutWidth(outImage.getWidth()); imageInfo.setOutHeight(outImage.getHeight()); if (scale < 1) { LogAppender.append("画像縮小"); if (imageInfo.rotateAngle!=0) LogAppender.append("回転"); LogAppender.println(": "+imageInfo.getOutFileName()+" ("+w+","+h+")→("+scaledW+","+scaledH+")"); } zos.flush(); } } catch (Exception e) { LogAppender.println("画像読み込みエラー: "+imageInfo.getOutFileName()); e.printStackTrace(); } }

ChatGPT:

いいところに来てます。これは「そのまま移植」よりも
Jimp前提に整理して書き直すのが正解です。

ポイントは👇

  • zosBuffer返却

  • InputStream / BufferedImageJimp

  • Javaの分岐 → 最小限に整理


Jimp版 writeImage(実用移植)

JavaScript

import Jimp from "jimp";  
  
export default class ImageUtils {  
  
  static async writeImage(  
    is,  
    srcImage,  
    imageInfo,  
    jpegQuality,  
    gammaOp,  
    maxImagePixels,  
    maxImageW,  
    maxImageH,  
    dispW,  
    dispH,  
    autoMarginLimitH,  
    autoMarginLimitV,  
    autoMarginWhiteLevel,  
    autoMarginPadding,  
    autoMarginNombre,  
    nombreSize  
  ) {  
    try {  
      const ext \= imageInfo.getExt();  
  
      //----------------------------------------  
      // 画像読み込み  
      //----------------------------------------  
      let image;  
  
      if (srcImage) {  
        image \= srcImage.clone();  
      } else {  
        const buffer \= Buffer.isBuffer(is) ? is : await streamToBuffer(is);  
        image \= await Jimp.read(buffer);  
      }  
  
      let w \= image.bitmap.width;  
      let h \= image.bitmap.height;  
  
      imageInfo.setOutWidth(w);  
      imageInfo.setOutHeight(h);  
  
      //----------------------------------------  
      // 回転  
      //----------------------------------------  
      if (imageInfo.rotateAngle) {  
        image.rotate(imageInfo.rotateAngle);  
        \[w, h\] \= \[image.bitmap.width, image.bitmap.height\];  
      }  
  
      //----------------------------------------  
      // 余白除去(簡易版)  
      //----------------------------------------  
      let margin \= null;  
  
      if (autoMarginLimitH \> 0 || autoMarginLimitV \> 0) {  
        margin \= this.getPlainMarginSimple(image, autoMarginWhiteLevel);  
  
        if (margin) {  
          const \[left, top, right, bottom\] \= margin;  
  
          const newW \= w \- left \- right;  
          const newH \= h \- top \- bottom;  
  
          image.crop(left, top, newW, newH);  
  
          w \= newW;  
          h \= newH;  
        }  
      }  
  
      //----------------------------------------  
      // 縮小計算  
      //----------------------------------------  
      let scale \= 1;  
  
      if (maxImagePixels \>= 10000) {  
        scale \= Math.sqrt(maxImagePixels / (w \* h));  
      }  
  
      if (maxImageW \> 0) scale \= Math.min(scale, maxImageW / w);  
      if (maxImageH \> 0) scale \= Math.min(scale, maxImageH / h);  
  
      //----------------------------------------  
      // リサイズ  
      //----------------------------------------  
      if (scale < 1) {  
        const newW \= Math.round(w \* scale);  
        const newH \= Math.round(h \* scale);  
  
        image.resize(newW, newH, Jimp.RESIZE\_BICUBIC);  
  
        LogAppender.println(  
          \`画像縮小: ${imageInfo.getOutFileName()} (${w},${h})→(${newW},${newH})\`  
        );  
  
        w \= newW;  
        h \= newH;  
      }  
  
      //----------------------------------------  
      // ガンマ補正(簡易)  
      //----------------------------------------  
      if (gammaOp) {  
        image.brightness(0); // placeholder  
        image.contrast(0.1);  
      }  
  
      //----------------------------------------  
      // 出力  
      //----------------------------------------  
      let buffer;  
  
      if (ext.startsWith("jp")) {  
        image.quality(Math.round(jpegQuality \* 100));  
        buffer \= await image.getBufferAsync(Jimp.MIME\_JPEG);  
      } else if (ext \=== "png") {  
        buffer \= await image.getBufferAsync(Jimp.MIME\_PNG);  
      } else {  
        buffer \= await image.getBufferAsync(Jimp.MIME\_PNG);  
      }  
  
      imageInfo.setOutWidth(w);  
      imageInfo.setOutHeight(h);  
  
      return buffer;  
  
    } catch (e) {  
      LogAppender.println("画像読み込みエラー: " + imageInfo.getOutFileName());  
      console.error(e);  
      return null;  
    }  
  }  
  
  //----------------------------------------  
  // 簡易余白検出(白ベース)  
  //----------------------------------------  
  static getPlainMarginSimple(image, whiteLevel \= 250) {  
    const w \= image.bitmap.width;  
    const h \= image.bitmap.height;  
  
    const isWhite \= (idx) => {  
      const r \= image.bitmap.data\[idx\];  
      const g \= image.bitmap.data\[idx + 1\];  
      const b \= image.bitmap.data\[idx + 2\];  
      return r \> whiteLevel && g \> whiteLevel && b \> whiteLevel;  
    };  
  
    let top \= 0, bottom \= 0, left \= 0, right \= 0;  
  
    // top  
    outer: for (let y \= 0; y < h; y++) {  
      for (let x \= 0; x < w; x++) {  
        const idx \= (y \* w + x) \* 4;  
        if (!isWhite(idx)) break outer;  
      }  
      top++;  
    }  
  
    // bottom  
    outer: for (let y \= h \- 1; y \>= 0; y\--) {  
      for (let x \= 0; x < w; x++) {  
        const idx \= (y \* w + x) \* 4;  
        if (!isWhite(idx)) break outer;  
      }  
      bottom++;  
    }  
  
    // left  
    outer: for (let x \= 0; x < w; x++) {  
      for (let y \= 0; y < h; y++) {  
        const idx \= (y \* w + x) \* 4;  
        if (!isWhite(idx)) break outer;  
      }  
      left++;  
    }  
  
    // right  
    outer: for (let x \= w \- 1; x \>= 0; x\--) {  
      for (let y \= 0; y < h; y++) {  
        const idx \= (y \* w + x) \* 4;  
        if (!isWhite(idx)) break outer;  
      }  
      right++;  
    }  
  
    if (top || bottom || left || right) {  
      return \[left, top, right, bottom\];  
    }  
  
    return null;  
  }  
}  
  
// stream → buffer  
async function streamToBuffer(stream) {  
  const chunks \= \[\];  
  for await (const chunk of stream) chunks.push(chunk);  
  return Buffer.concat(chunks);  
}

重要な差分(Java → JS)

Java JS
zos.write()
_writeImage(zos, ...)
return Buffer
BufferedImage Jimp
InputStream Buffer

移植レベルについて

このコードは:

✅ 回転
✅ リサイズ
✅ JPEG品質
✅ 余白削除(簡易)
✅ Buffer出力

をカバーしています。


未完全な部分(必要なら拡張)

Java版の完全再現には👇が必要

  • getPlainMargin()(精密版)

  • ノンブル除去

  • ガンマ完全再現

  • indexed color


次のステップ(おすすめ)

ここまで来たら次はこれ👇

👉 ImageUtilsを完全互換にするか
👉 軽量高速版にするか

選べます。

  • 完全互換 → Javaと同じ出力

  • 軽量版 → 10倍速い(Web向け)


希望あれば
👉「Kindle最適化込みの完全互換版」
👉「ブラウザ最適・爆速版」

どっちも出せます。

You:

JSに書き換えて、Jimpを使ってzosではなくbufferをreturnするようにして /** 画像を出力 マージン指定があればカット * //@param margin カットするピクセル数(left, top, right, bottom) */ static private void _writeImage(ZipArchiveOutputStream zos, BufferedImage srcImage, String ext, float jpegQuality) throws IOException { if ("png".equals(ext)) { /*//PNGEncoder kindlegenでエラーになるのと色が反映されない PngEncoder pngEncoder = new PngEncoder(); int pngColorType = PngEncoder.COLOR_TRUECOLOR; switch (srcImage.getType()) { case BufferedImage.TYPE_BYTE_BINARY: pngColorType = PngEncoder.COLOR_INDEXED; break; case BufferedImage.TYPE_BYTE_INDEXED: pngColorType = PngEncoder.COLOR_INDEXED; break; case BufferedImage.TYPE_BYTE_GRAY: pngColorType = PngEncoder.COLOR_GRAYSCALE; break; } pngEncoder.setColorType(pngColorType); pngEncoder.setCompression(PngEncoder.BEST_COMPRESSION); pngEncoder.setIndexedColorMode(PngEncoder.INDEXED_COLORS_AUTO); pngEncoder.encode(srcImage, zos); */ //ImageIO.write(srcImage, "PNG", zos); ImageWriter imageWriter = getPngImageWriter(); imageWriter.setOutput(ImageIO.createImageOutputStream(zos)); imageWriter.write(srcImage); } else if ("jpeg".equals(ext) || "jpg".equals(ext)) { ImageWriter imageWriter = getJpegImageWriter(); imageWriter.setOutput(ImageIO.createImageOutputStream(zos)); ImageWriteParam iwp = imageWriter.getDefaultWriteParam(); if (iwp.canWriteCompressed()) { try { iwp.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); iwp.setCompressionQuality(jpegQuality); imageWriter.write(null, new IIOImage(srcImage, null, null), iwp); } catch (Exception e) { e.printStackTrace(); } } else { imageWriter.write(srcImage); } } else if ("webp".equals(ext)) { // --- WebP出力処理 --- ImageWriter imageWriter = getWebpImageWriter(); imageWriter.setOutput(ImageIO.createImageOutputStream(zos)); ImageWriteParam param = imageWriter.getDefaultWriteParam(); if (param.canWriteCompressed()) { param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionType("Lossy"); // "Lossless" も可 param.setCompressionQuality(jpegQuality); // 0.0f~1.0f } imageWriter.write(null, new IIOImage(srcImage, null, null), param); imageWriter.dispose(); }else { ImageIO.write(srcImage, ext, zos); } zos.flush(); }

ChatGPT:

この _writeImage「BufferedImage → 指定フォーマットで書き出し」だけの関数なので、
Jimp ではかなりシンプルに置き換えできます。

ポイント👇

  • zos → ❌廃止

  • Buffer を return

  • フォーマット分岐だけ残す


Jimp版 _writeImage

JavaScript

import Jimp from "jimp";  
  
/\*\*  
 \* 画像を書き出して Buffer を返す  
 \*/  
static async \_writeImage(srcImage, ext, jpegQuality) {  
  try {  
  
    let image \= srcImage;  
  
    // 念のため clone(元画像破壊防止)  
    if (image.clone) {  
      image \= image.clone();  
    }  
  
    //----------------------------------------  
    // フォーマット別出力  
    //----------------------------------------  
    if (ext \=== "png") {  
  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
  
    } else if (ext \=== "jpeg" || ext \=== "jpg") {  
  
      image.quality(Math.round(jpegQuality \* 100));  
      return await image.getBufferAsync(Jimp.MIME\_JPEG);  
  
    } else if (ext \=== "webp") {  
  
      // JimpはWebP未対応(環境依存)  
      // fallbackとしてJPEGで出すかPNGにする  
      image.quality(Math.round(jpegQuality \* 100));  
      return await image.getBufferAsync(Jimp.MIME\_JPEG);  
  
    } else {  
  
      // 不明フォーマット → PNG fallback  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
    }  
  
  } catch (e) {  
    console.error("画像出力エラー:", e);  
    return null;  
  }  
}

Javaとの対応

Java JS
ImageWriter.write() getBufferAsync()
ImageIO.write() getBufferAsync()
zos ❌廃止
flush() ❌不要
jpegQuality image.quality()

重要ポイント

① JimpはWebPが弱い

Java版

webp対応あり

Jimp

基本未対応

👉 対策

  • JPEGにフォールバック(上のコード)

  • もしくは後で差し替え


② BufferedImage → Jimp変換

Java

BufferedImage

JS

Jimpインスタンス

なので _writeImage に渡す時点で

JavaScript

const image \= await Jimp.read(buffer);

にしておく必要があります。


③ zos削除による構造変化

Java

\_writeImage(zos, ...)

JS

JavaScript

const buffer \= await \_writeImage(...)  
zip.file(path, buffer)

まとめ

この変換で

  • Javaの ImageWriter

  • ZipArchiveOutputStream

を完全に排除して

👉 「画像 → Buffer → JSZip」構造

になります。


次にやるべきこと(かなり重要)

ここまで来たら👇

👉 _writeImage() は完成
👉 writeImage() はほぼ完成

残るボスはこれ👇

getPlainMargin()

これがないと

  • 余白除去

  • Kindle最適化

が崩れます


希望あれば
👉 Java版完全再現の getPlainMargin()(かなり難所)
👉 もしくは高速簡略版

どちらも出せます。

You:

node AozoraEpub3 -c "C:\Users\Owner\Downloads\kokoro.jpg" "C:\Users\Owner\Downloads\773_ruby_5968.zip" -------- . 変換開始 : C:\Users\Owner\Downloads\773_ruby_5968.zip 画像読み込みエラー: 0000.jpg TypeError: image.quality is not a function at ImageUtils._writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:382:13) at ImageUtils.writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:236:24) at Epub3Writer.writeCoverImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:911:33) at Epub3Writer.write (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:779:43) at async convertFile (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:543:5) at async main (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:471:7) C:\Users\Owner\Downloads/[夏目漱石] こころ.epubに出力されました。 変換完了[1.1s] : C:\Users\Owner\Downloads/[夏目漱石] こころ.epub PS C:\Users\Owner\Documents\GitHub\AozoraEpub3JS\src>

ChatGPT:

このエラーは原因がはっきりしています👇

TypeError: image.quality is not a function

👉 image が Jimp インスタンスではない


よくある原因(今回ほぼこれ)

あなたのコードの流れだと

JavaScript

\_writeImage(srcImage, ...)

に渡っている srcImage

  • Buffer

  • Uint8Array

  • 独自オブジェクト

のどれかになっていて、

JavaScript

image.quality(...)

が使えない状態です。


修正(必須)

_writeImage の先頭で Jimpに正規化します。

JavaScript

import Jimp from "jimp";  
  
static async \_writeImage(srcImage, ext, jpegQuality) {  
  try {  
  
    let image;  
  
    //----------------------------------------  
    // ★ここが重要  
    //----------------------------------------  
    if (srcImage instanceof Jimp) {  
      image \= srcImage.clone();  
    } else {  
      image \= await Jimp.read(srcImage); // Buffer対応  
    }  
  
    //----------------------------------------  
    // 出力  
    //----------------------------------------  
    if (ext \=== "png") {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
  
    } else if (ext \=== "jpeg" || ext \=== "jpg") {  
  
      // ★ここで初めて使える  
      image.quality(Math.round(jpegQuality \* 100));  
  
      return await image.getBufferAsync(Jimp.MIME\_JPEG);  
  
    } else {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
    }  
  
  } catch (e) {  
    console.error("画像出力エラー:", e);  
    return null;  
  }  
}

なぜ起きたか(AozoraEpub3移植あるある)

Java版

Java

BufferedImage srcImage

JS版

JavaScript

srcImage // ← 型がバラバラになる

特にここ👇

JavaScript

this.writeCoverImage(bookInfo.coverImage, ...)

bookInfo.coverImage

  • Jimp

  • Buffer

で混在しやすい


安定させるコツ(重要)

① ImageUtilsの入口を統一

JavaScript

// 常にJimpに変換  
const image \= await Jimp.read(buffer);

② 内部は全部Jimpで扱う

👉 型ブレをなくす


デバッグ方法(おすすめ)

一度これ入れると原因すぐ分かります👇

JavaScript

console.log("TYPE:", srcImage?.constructor?.name);

多分こう出ます👇

TYPE: Buffer

さらに安全にするなら

JavaScript

if (!srcImage) {  
  throw new Error("srcImage is null");  
}

まとめ

今回の本質👇

❌ Javaの BufferedImage 前提のまま
⭕ JSでは「Jimpに統一」する必要がある


次に起きる可能性のあるエラー(予告)

このあと高確率で👇が来ます

  • image.crop is not a function

  • image.bitmap undefined

👉 これも全部「Jimp化不足」が原因です


必要なら
👉 AozoraEpub3完全対応の ImageUtils(Jimp統一版)
出せます。これでこの系のバグ全部消えます。

You:

TypeError: Right-hand side of 'instanceof' is not callable at ImageUtils._writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:369:18) at ImageUtils.writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:236:24) at Epub3Writer.writeCoverImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:911:33) at Epub3Writer.write (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:779:43) at async convertFile (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:543:5) at async main (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:471:7)

ChatGPT:

これはよくある罠です👇

TypeError: Right-hand side of 'instanceof' is not callable

👉 Jimp がクラスとして読み込めていないのが原因です。


原因

今のコード:

JavaScript

if (srcImage instanceof Jimp)

でも実際の Jimp

  • 関数ではない

  • default export の中に入ってる

  • ESM/CJSの違いで形が変わる

👉 つまり instanceof が使えない状態


解決方法(確実)

❌ instanceof をやめる

これが一番安全です。


修正版(これが正解)

JavaScript

import Jimp from "jimp";  
  
static async \_writeImage(srcImage, ext, jpegQuality) {  
  try {  
  
    let image;  
  
    //----------------------------------------  
    // 型チェックをやめて統一処理  
    //----------------------------------------  
    if (srcImage && srcImage.bitmap) {  
      // Jimpっぽいオブジェクト  
      image \= srcImage.clone();  
    } else {  
      // Bufferなど → Jimpに変換  
      image \= await Jimp.read(srcImage);  
    }  
  
    //----------------------------------------  
    // 出力  
    //----------------------------------------  
    if (ext \=== "png") {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
  
    } else if (ext \=== "jpeg" || ext \=== "jpg") {  
  
      image.quality(Math.round(jpegQuality \* 100));  
  
      return await image.getBufferAsync(Jimp.MIME\_JPEG);  
  
    } else {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
    }  
  
  } catch (e) {  
    console.error("画像出力エラー:", e);  
    return null;  
  }  
}

なぜこれで解決するか

Jimpインスタンスは必ず👇を持っています

JavaScript

image.bitmap

なので

JavaScript

if (srcImage.bitmap)

で判定できます。


NGパターン(今回の原因)

JavaScript

import \* as Jimp from "jimp";  // ❌

または

JavaScript

const Jimp \= require("jimp"); // 環境によってはNG

正しいimport(重要)

ESMなら👇

JavaScript

import Jimp from "jimp";

それでも不安なら(デバッグ)

JavaScript

console.log(Jimp);  
console.log(typeof Jimp);

正常なら

function

または

object (with read function)

まとめ

今回の本質👇

  • instanceof Jimp → 壊れる

  • bitmapで判定 → 安定


AozoraEpub3移植の鉄則

👉 型判定しない

全部こうするのが一番安定👇

JavaScript

image \= await Jimp.read(srcImage);

(JimpはBufferもJimpも読める)


必要なら
👉 Jimpでの「完全型安全ImageUtils」
👉 Node + Browser両対応版

ここまで仕上げたやつ出せます。

You:

Owner\Downloads\kokoro.jpg" "C:\Users\Owner\Downloads\773_ruby_5968.zip" -------- . 変換開始 : C:\Users\Owner\Downloads\773_ruby_5968.zip 画像読み込みエラー: 0000.jpg TypeError: Jimp.read is not a function at ImageUtils._writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:374:26) at ImageUtils.writeImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/image/ImageUtils.js:236:24) at Epub3Writer.writeCoverImage (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:911:33) at Epub3Writer.write (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/writer/Epub3Writer.js:779:43) at async convertFile (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:543:5) at async main (file:///C:/Users/Owner/Documents/GitHub/AozoraEpub3JS/src/AozoraEpub3.js:471:7)

ChatGPT:

ここまで来ると原因はほぼ確定です👇

TypeError: Jimp.read is not a function

👉 Jimpのimportが壊れている(ESM/CJS問題)


原因(かなり重要)

jimp は環境によって export 形が違います。

今の状態はおそらく👇

JavaScript

import Jimp from "jimp";

でも実際の中身が

JavaScript

Jimp \= { default: ..., read: undefined }

になっている


解決方法(これで100%直る)

✅ 安全なimport方法

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default || JimpModule;

修正版コード

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default || JimpModule;  
  
static async \_writeImage(srcImage, ext, jpegQuality) {  
  try {  
  
    let image;  
  
    //----------------------------------------  
    // 常にJimpに変換(これが一番安全)  
    //----------------------------------------  
    if (srcImage && srcImage.bitmap) {  
      image \= srcImage.clone();  
    } else {  
      image \= await Jimp.read(srcImage);  
    }  
  
    //----------------------------------------  
    // 出力  
    //----------------------------------------  
    if (ext \=== "png") {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
  
    } else if (ext \=== "jpeg" || ext \=== "jpg") {  
      image.quality(Math.round(jpegQuality \* 100));  
      return await image.getBufferAsync(Jimp.MIME\_JPEG);  
  
    } else {  
      return await image.getBufferAsync(Jimp.MIME\_PNG);  
    }  
  
  } catch (e) {  
    console.error("画像出力エラー:", e);  
    return null;  
  }  
}

デバッグ確認(やってほしい)

JavaScript

console.log("Jimp:", Jimp);  
console.log("read:", typeof Jimp.read);

正常なら👇

read: function

なぜこうなるか

Jimpは内部的に

  • CommonJS

  • ESM

  • bundler

で挙動が変わる「地雷ライブラリ」です


一番安全な最終形(おすすめ)

実はこれが最強です👇

JavaScript

async function readJimp(src) {  
  const mod \= await import("jimp");  
  const Jimp \= mod.default || mod;  
  return await Jimp.read(src);  
}

AozoraEpub3移植での鉄則(超重要)

👉 ライブラリは必ずラップする

JavaScript

ImageUtils.readImage \= async (src) => {  
  return await readJimp(src);  
};

こうすると

  • Node

  • Browser

  • bundler

全部壊れなくなります


まとめ

今回の原因👇

  • Jimp.read が undefined

  • ✅ import形式の問題

解決👇

  • import * as JimpModule

  • default || module


ここまで直せば次はほぼ確実に👇に進みます

👉 余白除去(getPlainMargin)
👉 画像品質差(Javaとの差)


必要なら次に
JimpでJavaと同じ出力にするチューニング(かなり重要)
やります。

You:

import * as Jimp from 'jimp'; だった

ChatGPT:

それが原因です 👍

JavaScript

import \* as Jimp from 'jimp';

この形だと Jimp.read は存在しません


なぜ起きるか

jimp は実際には

JavaScript

module.exports \= Jimp;

の形(CommonJS)なので、

JavaScript

import \* as Jimp

すると中身はこうなります👇

JavaScript

Jimp \= {  
  default: JimpClass,  
  ...その他  
}

👉 つまり

JavaScript

Jimp.read ❌  
Jimp.default.read ⭕

正しい修正(2択)

✅ 方法①(おすすめ)

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default;

これで

JavaScript

await Jimp.read(...)

が使えます。


✅ 方法②(もっと安全)

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default || JimpModule;

👉 bundler / 環境差にも強い


修正後の確認

JavaScript

console.log(typeof Jimp.read);

👉 正常なら

function

NG例まとめ

JavaScript

import \* as Jimp from "jimp"; // ❌ 今回の原因
JavaScript

import Jimp from "jimp"; // ⚠ 環境によって壊れる

AozoraEpub3移植的ベストプラクティス

最終的にはこれにしておくと安定👇

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default || JimpModule;

補足(重要)

この修正で

  • Jimp.read is not a function ✅解決

  • instanceof 問題 ✅回避済み

  • quality is not a function ✅解決

👉 つまり今の画像処理系エラーは 全部解消ラインに到達してます


次に出るとしたら👇

  • 余白除去ズレ

  • 画像サイズ差(Javaとの差)


必要なら次は
👉 Java完全一致レベルの画像処理(Kindle最適化込み)
まで詰められます。

You:

console.log(typeof Jimp.read); undefined

ChatGPT:

OK、状況ハッキリしました 👍
👉 そのJimpは「defaultの中にさらに入ってるパターン」です


結論(これで直る)

JavaScript

import \* as JimpModule from "jimp";  
  
const Jimp \= JimpModule.default || JimpModule;  
  
// ★ ここがポイント  
const JimpClass \= Jimp.read ? Jimp : Jimp.default;

そして使うとき👇

JavaScript

const image \= await JimpClass.read(buffer);

なぜこうなるか

あなたの環境の jimp はこうなってます👇

JavaScript

JimpModule \= {  
  default: {  
    default: JimpClass,  
    read: undefined  
  }  
}

または

JavaScript

JimpModule \= {  
  default: JimpClass  
}

👉 bundler / Node / ESMの違いで崩壊してる


確実に動く書き方(最強)

これを使えば 100%回避できます

JavaScript

async function getJimp() {  
  const mod \= await import("jimp");  
  
  const j \=  
    mod.default?.read  
      ? mod.default  
      : mod.default?.default  
      ? mod.default.default  
      : mod;  
  
  return j;  
}

使い方👇

JavaScript

const Jimp \= await getJimp();  
const image \= await Jimp.read(buffer);

デバッグ(やると理解できる)

JavaScript

console.log(JimpModule);

多分こう見えます👇

JavaScript

{  
  default: {  
    default: \[Function Jimp\],  
    read: undefined  
  }  
}

もっとシンプルにするなら

正直これでOK👇

JavaScript

const Jimp \= (await import("jimp")).default.default;

ただし環境依存あるのでおすすめはしない


AozoraEpub3移植での結論

👉 Jimpはラップしないと必ず壊れる

なので最終的に👇

JavaScript

export async function readImage(src) {  
  const mod \= await import("jimp");  
  const Jimp \=  
    mod.default?.read  
      ? mod.default  
      : mod.default?.default  
      ? mod.default.default  
      : mod;  
  
  return await Jimp.read(src);  
}

ここまで来た状態

今のあなた👇

  • CLI動いてる ✅

  • JSZip移植済み ✅

  • ImageUtils移植中 ✅

  • Jimpハマり中(今ここ)⚠

👉 あと1歩で画像処理完成


もし希望あれば次👇

👉 「JimpでJavaと完全一致する画像出力(Kindle含む)」
👉 「ブラウザ対応版(Worker + Canvas)」

どっちも行けます。

*1:w-mw)*margin[0]/mLR); margin[2] = (int)((w-mw)*margin[2]/mLR); } } w = mw; h = mh; } } //倍率取得 double scale = 1; if (maxImagePixels >= 10000) scale = Math.sqrt((double)maxImagePixels/(w*h

*2:w-mw)*margin[0]/mLR); margin[2] = (int)((w-mw)*margin[2]/mLR); } } w = mw; h = mh; } } //倍率取得 double scale = 1; if (maxImagePixels >= 10000) scale = Math.sqrt((double)maxImagePixels/(w*h