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

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

AozoraEpub3のバグ、画像情報が空だった場合に止まるのを修正する

画像情報が空だった場合に止まるのを修正する

[ERROR] (65) エラーが発生しました : Cannot invoke "String.replaceFirst(String, String)" because "srcImageFileName" is null [ERROR] 書籍の情報が取得できませんでした

画像のalt属性を追加したときの処理の問題。

You:

[ERROR]  (65)
エラーが発生しました : Cannot invoke "String.replaceFirst(String, String)" because "srcImageFileName" is null
[ERROR] 書籍の情報が取得できませんでした

<img src=""/>

画像情報がからだった場合にnullが返される
    public String correctExt(String srcImageFileName) {
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        //拡張子修正
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".png");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".jpg");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".jpeg");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".gif");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".webp");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;


        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".PNG");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".JPG");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".JPEG");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".GIF");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".WEBP");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Png");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Jpg");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Jpeg");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Gif");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Webp");
        if (this.hasImage(srcImageFileName)) return srcImageFileName;
        
        return null;
    }
    

    public void addImageAlt(String fileName, String altText) {
        imageAltMap.put(correctExt(fileName), altText);
    }
    
    if (lowerChukiTag.startsWith("<img")) {
                    //src=の値抽出
                    String imageFileName = this.getTagAttr(chukiTag, "src");
                    if (imageFileName != null) {
                        imageInfoReader.addImageFileName(imageFileName);//画像がなければそのまま追加
                        if (bookInfo.firstImageLineNum == -1) {
                            //小さい画像は無視
                            ImageInfo imageInfo = imageInfoReader.getImageInfo(imageInfoReader.correctExt(imageFileName));
                            if (imageInfo != null && imageInfo.getWidth() > 64 && imageInfo.getHeight() > 64) {
                                bookInfo.firstImageLineNum = lineNum;
                                bookInfo.firstImageIdx = imageInfoReader.countImageFileNames()-1;
                            }
                        }
                    }
                    String altText = this.getTagAttr(chukiTag, "alt");
                    if (altText == null || altText.isEmpty()) {
                        altText = ""; // ファイル名を代替
                    }
                    imageInfoReader.addImageAlt(imageFileName, altText);
                }

ChatGPT:

原因はほぼ明確です。

<img src=""/> の場合、

Java

String imageFileName \= this.getTagAttr(chukiTag, "src");

"" を返し、その後

Java

imageInfoReader.addImageAlt(imageFileName, altText);

が実行されます。

addImageAlt() 内で、

Java

imageAltMap.put(correctExt(fileName), altText);

correctExt("")

となります。

ただし、今回の例外メッセージは

Cannot invoke "String.replaceFirst(...)" because "srcImageFileName" is null

なので、getTagAttr()src="" に対して 空文字ではなく null を返している可能性が高いです。

そして correctExt() の最初に、

Java

if (this.hasImage(srcImageFileName))

がありますが、hasImage(null)false を返した後、

Java

srcImageFileName.replaceFirst(...)

に到達して NPE になっています。

一番安全な修正

correctExt() 自体を null / 空文字に対応させるのがおすすめです。

Java

public String correctExt(String srcImageFileName) {

    // null または空文字なら画像なし

    if (srcImageFileName \== null || srcImageFileName.isEmpty()) {

        return null;

    }

  

    if (this.hasImage(srcImageFileName)) {

        return srcImageFileName;

    }

  

    // 拡張子修正

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".png");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".jpg");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".jpeg");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".gif");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".webp");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".PNG");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".JPG");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".JPEG");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".GIF");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".WEBP");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".Png");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".Jpg");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".Jpeg");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".Gif");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".Webp");

    if (this.hasImage(srcImageFileName)) return srcImageFileName;

  

    return null;

}

ただし、これだけでは不十分です。

addImageAlt() も修正したほうがいいです。

Java

public void addImageAlt(String fileName, String altText) {

    String correctFileName \= correctExt(fileName);

  

    if (correctFileName \== null) {

        return;

    }

  

    imageAltMap.put(correctFileName, altText);

}

さらに、今回の処理なら src がない画像を addImageFileName() に入れる必要もありません。

現在は、

Java

if (lowerChukiTag.startsWith("<img")) {

    String imageFileName \= this.getTagAttr(chukiTag, "src");

  

    if (imageFileName != null) {

        imageInfoReader.addImageFileName(imageFileName);

  

        ...

    }

  

    String altText \= this.getTagAttr(chukiTag, "alt");

    if (altText \== null || altText.isEmpty()) {

        altText \= "";

    }

  

    imageInfoReader.addImageAlt(imageFileName, altText);

}

となっていますが、ここを 画像ファイル名が有効な場合だけ alt も登録するようにすると綺麗です。

Java

if (lowerChukiTag.startsWith("<img")) {

    String imageFileName \= this.getTagAttr(chukiTag, "src");

  

    if (imageFileName != null && !imageFileName.isEmpty()) {

        imageInfoReader.addImageFileName(imageFileName);

  

        if (bookInfo.firstImageLineNum \== \-1) {

            // 小さい画像は無視

            ImageInfo imageInfo \=

                imageInfoReader.getImageInfo(

                    imageInfoReader.correctExt(imageFileName)

                );

  

            if (imageInfo != null

                    && imageInfo.getWidth() \> 64

                    && imageInfo.getHeight() \> 64) {

  

                bookInfo.firstImageLineNum \= lineNum;

                bookInfo.firstImageIdx \=

                    imageInfoReader.countImageFileNames() \- 1;

            }

        }

  

        String altText \= this.getTagAttr(chukiTag, "alt");

  

        if (altText \== null || altText.isEmpty()) {

            altText \= "";

        }

  

        imageInfoReader.addImageAlt(imageFileName, altText);

    }

}

さらに重要な点

今回の

HTML

<img src\=""/>

は、そもそも画像として扱う必要がありません

したがって私は、

  1. correctExt()null / 空文字を受けても落ちないようにする

  2. <img>src が空なら画像登録処理そのものをスキップする

という二重防御をおすすめします。

特に correctExt() は他の場所からも呼ばれている可能性があるので、

Java

if (srcImageFileName \== null || srcImageFileName.isEmpty()) {

    return null;

}

を入れておく価値があります。

なお、もう一つ気になるのが correctExt() の拡張子置換です。現在は .png.jpg.jpeg ……と**同じ文字列に対して何度も replaceFirst()**しています。これは意図した動作にはなっていますが、拡張子を一度抽出して候補を順番に試す実装にしたほうがかなり安全です。必要なら、ここを PNG/JPG/JPEG/GIF/WEBPの大文字小文字を全部まとめて処理する形に整理できます。

You:

[SwingWorker-pool-2-thread-1] ERROR com.github.hmdev.image.ImageInfoReader - e: java.io.FileNotFoundException: C:\Users\Owner\Documents\GitHub\AozoraEpub3\test_data (�A�N�Z�X�����ۂ���܂����B) at java.base/java.io.FileInputStream.open0(Native Method) at java.base/java.io.FileInputStream.open(FileInputStream.java:185) at java.base/java.io.FileInputStream.(FileInputStream.java:139) at com.github.hmdev.info.ImageInfo.getImageInfo(ImageInfo.java:55) at com.github.hmdev.image.ImageInfoReader.getImageInfo(ImageInfoReader.java:217) at com.github.hmdev.image.ImageInfoReader.getImageInfo(ImageInfoReader.java:137) at com.github.hmdev.writer.Epub3Writer.getImageFilePath(Epub3Writer.java:1074) at com.github.hmdev.converter.AozoraEpub3Converter.convertTextLineToEpub3(AozoraEpub3Converter.java:2164) at com.github.hmdev.converter.AozoraEpub3Converter.convertTextToEpub3(AozoraEpub3Converter.java:1275) at com.github.hmdev.writer.Epub3Writer.writeSections(Epub3Writer.java:974) at com.github.hmdev.writer.Epub3Writer.write(Epub3Writer.java:494) at AozoraEpub3.convertFile(AozoraEpub3.java:534) at AozoraEpub3Applet.convertFile(AozoraEpub3Applet.java:3902) at AozoraEpub3Applet.convertFile(AozoraEpub3Applet.java:3517) at AozoraEpub3Applet.convertFiles(AozoraEpub3Applet.java:3463) at AozoraEpub3Applet.convertFiles(AozoraEpub3Applet.java:3439) at AozoraEpub3Applet.convertFiles(AozoraEpub3Applet.java:3259) at AozoraEpub3Applet$ConvertWorker.doInBackground(AozoraEpub3Applet.java:4204) at java.desktop/javax.swing.SwingWorker$1.call(SwingWorker.java:303) at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:330) at java.desktop/javax.swing.SwingWorker.run(SwingWorker.java:340) at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) at java.base/java.lang.Thread.run(Thread.java:1516)

/** ファイルから画像情報を生成 */
static public ImageInfo getImageInfo(File imageFile) throws IOException
{
    BufferedInputStream bis = new BufferedInputStream(new FileInputStream(imageFile));
    ImageInfo imageInfo = ImageInfo.getImageInfo(bis);
    bis.close();
    return imageInfo;
}

ChatGPT:

これは先ほどの src="" とは少し別の問題です。

スタックトレースを見る限り、

java.io.FileNotFoundException:

C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data

ImageInfo.getImageInfo() に渡しています。

つまり、ImageInfo.getImageInfo(File imageFile) 自体が悪いというより、getImageInfo() に渡される File が「画像ファイル」ではなく test_data になっているのが問題です。

まず確認したい箇所

ここです。

Epub3Writer.getImageFilePath(Epub3Writer.java:1074)

    ↓

ImageInfoReader.getImageInfo(ImageInfoReader.java:137)

    ↓

ImageInfoReader.\_getImageInfo(ImageInfoReader.java:217)

    ↓

ImageInfo.getImageInfo(ImageInfo.java:55)

特に、

Java

Epub3Writer.java:1074

getImageInfo() に何を渡しているかを確認してください。

おそらく最終的に、

Java

new File("C:\\\\Users\\\\Owner\\\\Documents\\\\GitHub\\\\AozoraEpub3\\\\test\_data")

のような File が生成されています。


ImageInfo.getImageInfo() に防御処理を入れる

現在、

Java

static public ImageInfo getImageInfo(File imageFile) throws IOException

{

    BufferedInputStream bis \=

        new BufferedInputStream(new FileInputStream(imageFile));

  

    ImageInfo imageInfo \= ImageInfo.getImageInfo(bis);

    bis.close();

    return imageInfo;

}

ですが、ここは少なくともファイルかどうかを確認したほうがいいです。

Java

/\*\* ファイルから画像情報を生成 \*/

static public ImageInfo getImageInfo(File imageFile) throws IOException

{

    if (imageFile \== null) {

        return null;

    }

  

    if (!imageFile.isFile()) {

        return null;

    }

  

    try (BufferedInputStream bis \=

            new BufferedInputStream(new FileInputStream(imageFile))) {

  

        return ImageInfo.getImageInfo(bis);

    }

}

これなら、

test\_data

がディレクトリだった場合も FileNotFoundException を発生させずに null を返せます。

try-with-resources にしたのも重要です。


ただし、根本原因はこちら

今回、

C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data

というパスが出ているのがかなり重要です。

正常なら、

C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data\\image.jpg

のように画像ファイルまで含まれている必要があります

つまり、

test\_data

が画像の親ディレクトリとして扱われてしまっています。

例えば本来、

Java

File imageFile \= new File(imageDir, imageFileName);

となるべきところで、

Java

File imageFile \= imageDir;

のようになっている可能性があります。


今回の流れで特に怪しいところ

先ほどの <img src=""> 対応を入れたのであれば、私は ImageInfoReader のこの辺を確認します。

Java

\_getImageInfo(...)

getImageInfo(...)

特に、

Java

File imageFile \= ...

を作っているところです。

そして一時的に、

Java

System.out.println("imageFile = " + imageFile);

System.out.println("exists     = " + imageFile.exists());

System.out.println("isFile     = " + imageFile.isFile());

System.out.println("isDirectory= " + imageFile.isDirectory());

を入れると原因がすぐ分かります。

今回なら、おそらく、

imageFile = C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data

exists     = true

isFile     = false

isDirectory= true

となるはずです。


さらに重要:correctExt()null を返すケース

先ほどの修正後に、

Java

imageInfoReader.getImageInfo(

    imageInfoReader.correctExt(imageFileName)

);

としている場合は注意してください。

例えば、

HTML

<img src\=""/>

なら、

Java

correctExt("")

null

になります。

そして getImageInfo(null) がどう処理されるかによって、別の問題が発生します。

なのでここも、

Java

String correctFileName \=

    imageInfoReader.correctExt(imageFileName);

  

if (correctFileName != null) {

    ImageInfo imageInfo \=

        imageInfoReader.getImageInfo(correctFileName);

  

    if (imageInfo != null

            && imageInfo.getWidth() \> 64

            && imageInfo.getHeight() \> 64) {

  

        bookInfo.firstImageLineNum \= lineNum;

        bookInfo.firstImageIdx \=

            imageInfoReader.countImageFileNames() \- 1;

    }

}

としておくのが安全です。


結論

今回のエラーは、

FileInputStream("...\\\\test\_data")

になっていることが直接の原因です。

優先順位としては、

  1. Epub3Writer.java:1074getImageInfo() に何を渡しているか確認

  2. ImageInfo.getImageInfo(File)isFile() の防御を追加

  3. correctExt()null を返した場合は getImageInfo() を呼ばない

  4. <img src=""> は画像登録自体をスキップ

という順番で直すのがいいです。

特に Epub3Writer.java:1074 の前後20~30行を見れば、「なぜ test_data が画像ファイルとして渡っているのか」までかなり正確に追えます。

You:

public String getImageFilePath(String srcImageFileName, int lineNum) throws IOException
{
    boolean isCover = false;

    ImageInfo imageInfo = this.imageInfoReader.getImageInfo(srcImageFileName);
    //拡張子修正
    if (imageInfo == null) {
        //画像があるかチェック
        String altImageFileName = this.imageInfoReader.correctExt(srcImageFileName);
        imageInfo = this.imageInfoReader.getImageInfo(altImageFileName);
        if (imageInfo != null) {
            LogAppender.warn(lineNum, "画像拡張子変更", srcImageFileName);
            srcImageFileName = altImageFileName;
        }
    }
    this.imageIndex++; //0001から開始 (本文内の順番に合せるため、ファイルが無くてもカウント)
    if (imageInfo != null) {
        String imageId = imageInfo.getId();
        //画像は未だ出力されていない
        if (imageId == null) {
            imageId = decimalFormat.format(this.imageIndex);
            this.imageInfos.add(imageInfo);
            this.outImageFileNames.add(srcImageFileName);
            if (this.imageIndex-1 == this.bookInfo.coverImageIndex) {
                //imageInfo.setIsCover(true);
                isCover = true;
            }
        }
        String outImageFileName = imageId+"."+imageInfo.getExt().replaceFirst("jpeg", "jpg");
        imageInfo.setId(imageId);
        imageInfo.setOutFileName(outImageFileName);

        //先頭に表紙ページ移動の場合でカバーページならnullを返して本文中から削除
        if (bookInfo.insertCoverPage && isCover) return null;
        return "../"+IMAGES_PATH+outImageFileName;
    } else {
        LogAppender.warn(lineNum, "画像ファイルなし", srcImageFileName);
    }
    return null;
}

ChatGPT:

はい、このコードを見ると原因がかなり絞れます。

問題はこの部分です。

Java

ImageInfo imageInfo \= this.imageInfoReader.getImageInfo(srcImageFileName);

そして、

Java

if (imageInfo \== null) {

    String altImageFileName \=

        this.imageInfoReader.correctExt(srcImageFileName);

  

    imageInfo \= this.imageInfoReader.getImageInfo(altImageFileName);

です。

srcImageFileNamenull または空文字なのに、そのまま getImageInfo() に渡しています。

さらに今回のログでは、

C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data

FileInputStream に渡されています。

つまり ImageInfoReader.getImageInfo() が、画像ファイル名として渡された文字列をパスとして解決した結果、test_data ディレクトリそのものを開こうとしている状態です。

getImageFilePath() で最初に弾くのが一番いい

このメソッドは「画像をEPUBに出力するためのパスを取得する」メソッドなので、null / 空文字を受け取ったらここで終了させるのが自然です。

Java

public String getImageFilePath(String srcImageFileName, int lineNum) throws IOException

{

    boolean isCover \= false;

  

    // srcがない画像は無視

    if (srcImageFileName \== null || srcImageFileName.isBlank()) {

        return null;

    }

  

    ImageInfo imageInfo \=

        this.imageInfoReader.getImageInfo(srcImageFileName);

  

    // 拡張子修正

    if (imageInfo \== null) {

        String altImageFileName \=

            this.imageInfoReader.correctExt(srcImageFileName);

  

        if (altImageFileName != null) {

            imageInfo \=

                this.imageInfoReader.getImageInfo(altImageFileName);

  

            if (imageInfo != null) {

                LogAppender.warn(

                    lineNum,

                    "画像拡張子変更",

                    srcImageFileName

                );

  

                srcImageFileName \= altImageFileName;

            }

        }

    }

  

    this.imageIndex++; // 0001から開始(本文内の順番に合わせるため、ファイルがなくてもカウント)

  

    if (imageInfo != null) {

        String imageId \= imageInfo.getId();

  

        // 画像は未だ出力されていない

        if (imageId \== null) {

            imageId \= decimalFormat.format(this.imageIndex);

  

            this.imageInfos.add(imageInfo);

            this.outImageFileNames.add(srcImageFileName);

  

            if (this.imageIndex \- 1 \== this.bookInfo.coverImageIndex) {

                isCover \= true;

            }

        }

  

        String outImageFileName \=

            imageId + "." +

            imageInfo.getExt().replaceFirst("jpeg", "jpg");

  

        imageInfo.setId(imageId);

        imageInfo.setOutFileName(outImageFileName);

  

        // 先頭に表紙ページ移動の場合でカバーページなら本文中から削除

        if (bookInfo.insertCoverPage && isCover) {

            return null;

        }

  

        return "../" + IMAGES\_PATH + outImageFileName;

  

    } else {

        LogAppender.warn(

これで、

HTML

<img src\=""\>

HTML

<img\>

などから getImageFilePath() が呼ばれても、画像ファイル検索まで進みません。


ただし、今回の test_data 問題はもう一段確認したい

src="" が原因なら、今回のスタックトレースは通常、

FileNotFoundException: ...\\test\_data\\

のような形になる可能性があります。

ところが実際には、

FileNotFoundException:

C:\\Users\\Owner\\Documents\\GitHub\\AozoraEpub3\\test\_data

となっています。

これは ImageInfoReader 側で画像ファイル名を解決するときに、

test\_data + srcImageFileName

ではなく、

test\_data

だけになっている可能性があります。

なので、次に見るべきなのは ImageInfoReader.getImageInfo() です。

おそらく、

Java

public ImageInfo getImageInfo(String fileName)

の中で最終的に、

Java

File imageFile \= new File(...);

を作っていると思います。


そして correctExt() も少し整理したほうがいい

現在のコードでは、

Java

String altImageFileName \=

    this.imageInfoReader.correctExt(srcImageFileName);

  

imageInfo \=

    this.imageInfoReader.getImageInfo(altImageFileName);

となっています。

ここで correctExt() が画像を見つけられなかった場合、

Java

return null;

します。

そのため、

Java

getImageInfo(null)

を呼ばないようにする必要があります。

今回のように、

Java

if (altImageFileName != null) {

    imageInfo \=

        this.imageInfoReader.getImageInfo(altImageFileName);

}

としておけば安全です。


さらに ImageInfo.getImageInfo(File) も防御する

最終防衛線として、ここも変更しておくのがおすすめです。

Java

/\*\* ファイルから画像情報を生成 \*/

static public ImageInfo getImageInfo(File imageFile) throws IOException

{

    if (imageFile \== null || !imageFile.isFile()) {

        return null;

    }

  

    try (BufferedInputStream bis \=

            new BufferedInputStream(new FileInputStream(imageFile))) {

  

        return ImageInfo.getImageInfo(bis);

    }

}

これなら、

null

ディレクトリ

が来ても FileInputStream で例外になりません。


今回の修正方針

私は 3箇所で防御するのがいいと思います。

HTML

 ↓

<img src="">

 ↓

getImageFilePath()

 ↓

① null / 空文字なら return null

 ↓

ImageInfoReader

 ↓

② correctExt() の null を処理

 ↓

ImageInfo.getImageInfo(File)

 ↓

③ isFile() を確認

これなら、HTML編集時にTinyMCEが生成するような

HTML

<img src\=""\>

だけでなく、

HTML

<img\>

<img src\="存在しない画像.png"\>

<img src\="画像.jpg"\>

なども安全に処理できます。

特に今回のAozoraEpub3のような変換処理では、「画像がない」こと自体を例外にしない設計にしておくのが適しています。

なお、getImageFilePath()imageIndex++ は現在「画像ファイルがなくてもカウントする」という意図なので、src が空の場合にそれまで実行するかどうかは別途判断が必要です。元のコメント通り本文中の <img> の出現順を維持したいなら、今の早期 return ではなく imageIndex++ を先に残す設計もできます。

You:

correctExt() の拡張子置換のリファクタリング

public String correctExt(String srcImageFileName) {
    // null または空文字なら画像なし
    if (srcImageFileName == null || srcImageFileName.isEmpty()) {
        return null;
    }
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    //拡張子修正
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".png");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".jpg");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".jpeg");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".gif");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".webp");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;


    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".PNG");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".JPG");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".JPEG");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".GIF");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".WEBP");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;

    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Png");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Jpg");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Jpeg");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Gif");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;
    srcImageFileName = srcImageFileName.replaceFirst("\\.\\w+$", ".Webp");
    if (this.hasImage(srcImageFileName)) return srcImageFileName;

    return null;
}

ChatGPT:

はい。ここはかなりきれいにできます。

現在の実装の問題は、拡張子の候補ごとに replaceFirst() を繰り返していることです。

例えば、

Java

srcImageFileName \= srcImageFileName.replaceFirst("\\\\.\\\\w+$", ".png");

をしたあとに .jpg に置換しているため、結果としては動きますが、かなり冗長です。

おすすめのリファクタリング

拡張子候補を配列にしてループします。

Java

public String correctExt(String srcImageFileName) {

    // null または空文字なら画像なし

    if (srcImageFileName \== null || srcImageFileName.isEmpty()) {

        return null;

    }

  

    // 元のファイル名が存在する場合

    if (this.hasImage(srcImageFileName)) {

        return srcImageFileName;

    }

  

    // 拡張子を取得

    int extPos \= srcImageFileName.lastIndexOf('.');

    // 拡張子がない場合は修正できない

    if (extPos < 0) {

        return null;

    }

  

    // 拡張子以外の部分

    String baseName \= srcImageFileName.substring(0, extPos);

  

    // 対応する拡張子

    String\[\] extensions \= {

        ".png",

        ".jpg",

        ".jpeg",

        ".gif",

        ".webp",

        ".PNG",

        ".JPG",

        ".JPEG",

        ".GIF",

        ".WEBP",

        ".Png",

        ".Jpg",

        ".Jpeg",

        ".Gif",

        ".Webp"

    };

  

    for (String extension : extensions) {

        String imageFileName \= baseName + extension;

  

        if (this.hasImage(imageFileName)) {

            return imageFileName;

        }

    }

  

    return null;

}

これだけで現在の処理とほぼ同じ動作になります。


さらにおすすめ:大文字小文字を全部列挙しない

ただ、Javaであれば、さらに整理できます。

現在は、

.png

.PNG

.Png

など15パターンを列挙しています。

対応したいのが「拡張子の大文字小文字の違い」だけなら、実際には画像ファイル名のベース部分を取り出して、

Java

.png

.jpg

.jpeg

.gif

.webp

だけを試せば十分です。

ただし、hasImage()Windows上で実際にファイルシステムを見ているのであれば、Windowsは通常ファイル名の大文字小文字を区別しません。

つまり、

image.png

image.PNG

image.Png

はWindowsでは同じファイルとして扱われます。

そのため、Windowsで使うAozoraEpub3なら、基本的には次で十分です。

Java

public String correctExt(String srcImageFileName) {

    // null または空文字なら画像なし

    if (srcImageFileName \== null || srcImageFileName.isEmpty()) {

        return null;

    }

  

    // 元のファイル名が存在する場合

    if (this.hasImage(srcImageFileName)) {

        return srcImageFileName;

    }

  

    // 拡張子を取得

    int extPos \= srcImageFileName.lastIndexOf('.');

  

    // 拡張子がない場合

    if (extPos < 0) {

        return null;

    }

  

    String baseName \= srcImageFileName.substring(0, extPos);

  

    String\[\] extensions \= {

        ".png",

        ".jpg",

        ".jpeg",

        ".gif",

        ".webp"

    };

  

    for (String extension : extensions) {

        String imageFileName \= baseName + extension;

  

        if (this.hasImage(imageFileName)) {

            return imageFileName;

        }

    }

  

    return null;

}

ただし注意

ここで重要なのは、hasImage() の実装です。

もし hasImage() が、

Java

new File(...).exists()

のようにWindowsのファイルシステムを使っているなら、上記で問題ありません。

一方、AozoraEpub3が将来的にLinux/macOSでも動かすことを考えるなら、大文字小文字は区別されるため、

image.png

image.PNG

image.Png

を区別する必要があります。

その場合は、現在の15パターンをループにまとめる方法が適しています。


私ならこうする

AozoraEpub3のコードとしては、拡張子候補を定数化しておくのが一番読みやすいと思います。

Java

private static final String\[\] IMAGE\_EXTENSIONS \= {

    ".png",

    ".jpg",

    ".jpeg",

    ".gif",

    ".webp",

    ".PNG",

    ".JPG",

    ".JPEG",

    ".GIF",

    ".WEBP",

    ".Png",

    ".Jpg",

    ".Jpeg",

    ".Gif",

    ".Webp"

};

  

public String correctExt(String srcImageFileName) {

    // null または空文字なら画像なし

    if (srcImageFileName \== null || srcImageFileName.isEmpty()) {

        return null;

    }

  

    // 元のファイル名が存在する場合

    if (this.hasImage(srcImageFileName)) {

        return srcImageFileName;

    }

  

    // 拡張子がない場合は修正できない

    int extPos \= srcImageFileName.lastIndexOf('.');

    if (extPos < 0) {

        return null;

    }

  

    String baseName \= srcImageFileName.substring(0, extPos);

  

    for (String extension : IMAGE\_EXTENSIONS) {

        String imageFileName \= baseName + extension;

  

        if (this.hasImage(imageFileName)) {

            return imageFileName;

        }

    }

  

    return null;

}

これなら今後、

Java

".avif"

などを追加したくなった場合も、

Java

private static final String\[\] IMAGE\_EXTENSIONS \= {

    ".png",

    ".jpg",

    ".jpeg",

    ".gif",

    ".webp",

    ".avif"

};

とするだけです。


もう一つ改善するなら

実はこの部分、

Java

int extPos \= srcImageFileName.lastIndexOf('.');

にも少し問題があります。

例えば、

images/book.v1/image

や、

images/.hidden

のようなパスを考えると、単純な lastIndexOf('.') よりも、最後の / または \ より後にある . だけを拡張子として扱うほうが堅牢です。

AozoraEpub3は画像パスを扱うので、私は最終的には Path/File ベースではなく、青空文庫の画像パス形式を壊さない文字列ベースの拡張子判定としてそこまで考慮した実装にするのをおすすめします。

HTMLを青空文庫に変換する良い方法

You:

HTMLを青空文庫に変換する良い方法

ChatGPT:

HTMLを「青空文庫形式」にしたい、という意味なら、**HTML → 青空文庫テキスト(.txt)**への変換として考えるのが一番いいです。

青空文庫は基本的に、青空文庫形式のテキストを作成 → txt2xhtml などでXHTML化という流れです。HTMLを直接青空文庫XHTMLへ変換するより、この中間形式を挟む方が安定します。青空文庫 組版部+1

おすすめの変換パイプライン

HTML

 ↓

HTMLパーサー

 ↓

本文のDOMを解析

 ↓

青空文庫形式テキスト

 ↓

txt2xhtml

 ↓

青空文庫XHTML

例えばHTMLが、

HTML

<p\>これは<span class\="ruby"\>青空文庫</span\>です。</p\>

のような構造なら、最終的には

これは青空文庫です。

あるいはルビ情報を持っているなら、

これは青空文庫《あおぞらぶんこ》です。

のように変換します。

青空文庫のルビは 《》、複雑な範囲指定には 、レイアウト指定には [#…] を使います。青空文庫+1


ただし、HTMLの種類によって方法が変わる

例えば、

① 普通の小説HTML

HTML

<h1\>吾輩は猫である</h1\>

<p\>吾輩は猫である。</p\>

<p\>まだ名前はない。</p\>

なら比較的簡単です。

吾輩は猫である

  

吾輩は猫である。

まだ名前はない。

という変換ができます。

② HTMLにルビがある

HTML

<ruby\>

  青空文庫

  <rt\>あおぞらぶんこ</rt\>

</ruby\>

青空文庫《あおぞらぶんこ》

これはかなり相性がいいです。

③ 縦書き用HTML

HTML

<div class\="tate"\>

  ...

</div\>

の場合は、CSSそのものを変換するのではなく、

[#3字下げ]

本文

などの青空文庫注記に意味を変換する必要があります。


私ならNode.jsで作る

これまで使っているNode.js環境を考えると、専用の変換プログラムを作るのが一番扱いやすいと思います。

例えば、

html-to-aozora/

├─ src/

│  ├─ parser.js

│  ├─ ruby.js

│  ├─ heading.js

│  ├─ indent.js

│  ├─ image.js

│  └─ converter.js

├─ input/

│  └─ novel.html

└─ output/

   └─ novel.txt

という構成です。

HTML解析には jsdomcheerio を使えます。

変換ルールを、

<h1> → [#大見出し]

<h2> → [#中見出し]

<p>  → 段落

<br> → 改行

<ruby> → 《ルビ》

<em> → 傍点など

という感じでDOMから青空文庫記法へ落とします。


特に重要なのが「HTML → 青空文庫形式」の設計

単純なHTMLタグ置換だけにすると、後でかなり苦しくなります。

例えば、

HTML

<p\>

  これは

  <ruby\>日本語<rt\>にほんご</rt\></ruby\>

  の文章です。

</p\>

を文字列置換するのではなく、

HTML DOM

   ↓

Block

 ├─ Text

 ├─ Ruby

 │   ├─ base = 日本語

 │   └─ ruby = にほんご

 └─ Text

   ↓

青空文庫Writer

という中間表現を作るのがおすすめです。

そうすると将来的に、

HTML

 ↓

Document Model

 ├── Text

 ├── Ruby

 ├── Heading

 ├── Paragraph

 ├── Indent

 ├── Image

 └── Annotation

 ↓

Aozora TXT

とできます。

さらに同じDocument Modelから、

青空文庫TXT

EPUB

XHTML

Markdown

などにも出せます。


そして、今の用途なら「HTML → 青空文庫TXT → AozoraEpub3」がかなり良い

特にあなたが既にAozoraEpub3を使っているのであれば、

Web小説HTML

       ↓

自作 Node.js HTML parser

       ↓

青空文庫形式 TXT

       ↓

AozoraEpub3

       ↓

EPUB

という構成にすると非常に相性がいいです。

青空文庫自身もテキスト版を元にXHTML版を生成する設計なので、この考え方は本来の形式にも沿っています。青空文庫 組版部

もし「青空文庫のHTML(XHTML)を入力にして、青空文庫TXTへ戻したい」という意味なら、さらに簡単にできます。
その場合は、青空文庫XHTML特有の <ruby>, <div class="jisage">, <div class="main_text"> などを解析して、かなり高精度に元の青空文庫形式へ復元するNode.js変換器を設計できます。

You:

turndownのルールを変更したが改行が削除される

ChatGPT: 原因は、paragraph ルールそのものよりも、Turndown内部の「ブロック要素間の改行処理」と、後段での改行整理が絡んでいる可能性が高いです。

特に青空文庫用なら、p

JavaScript

return '\\n' + content + '\\n'

にするより、Turndown内部では通常どおり \n\n を維持して、変換終了後に \n\n → \n とする方法が安定します。

まず試してほしい方法

paragraph を元に戻します。

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    return '\\n\\n' + content + '\\n\\n'

  }

}

そしてTurndown実行後に、

JavaScript

let result \= turndownService.turndown(html)

  

result \= result.replace(/\\n{2,}/g, '\\n')

とします。

ただし、これだけだと意図的な空行まで全部消えてしまうので、青空文庫変換ならもう少し限定した方がいいです。


なぜ \n にすると問題が出るのか

例えばHTMLが、

HTML

<p\>一行目</p\>

<p\>二行目</p\>

<p\>三行目</p\>

だった場合、Turndown内部ではブロック要素を、

<p>一行目</p>

<p>二行目</p>

<p>三行目</p>

という単純な文字列置換として扱っているわけではありません。

p はブロック要素なので、Turndownは周辺のブロック要素との間隔も考慮します。

そのため、

JavaScript

return '\\n' + content + '\\n'

にすると、

一行目

二行目

三行目

のような状態になることがあります。

さらにTurndownの別の処理で前後の改行が整理されると、

一行目二行目三行目

のように見えるケースがあります。


青空文庫変換なら「段落」と「改行」を分けた方がいい

ここがかなり重要です。

青空文庫では、

HTML

<p\>これは第一段落です。</p\>

<p\>これは第二段落です。</p\>

これは第一段落です。

これは第二段落です。

にしたいわけですよね。

一方、

HTML

<p\>これは一行目です。<br\>

これは二行目です。</p\>

は、

これは一行目です。

これは二行目です。

です。

つまり、

HTML 青空文庫
<p> 段落境界
<br> 明示的改行
<h1> 見出し
<ruby> ルビ
<span class="em-sesame"> 傍点

となります。

そのため、pbr の両方で同じように改行を生成する設計にすると、後々問題が出やすいです。


おすすめの実装

私は今回の用途なら、paragraph をこうします。

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    content \= content.trim()

  

    if (!content) {

      return ''

    }

  

    return content + '\\n'

  }

}

そして br は、

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function (content, node, options) {

    return '\\n'

  }

}

にします。

つまり、

HTML

<p\>第一段落</p\>

<p\>第二段落</p\>

第一段落

第二段落

そして、

HTML

<p\>第一行<br\>第二行</p\>

第一行

第二行

です。


ただし trim() には注意

今回のコードには、

JavaScript

content \= content.trim()

を入れていますが、これはルビや注記の前後に意味のある空白が存在するHTMLでは問題になる可能性があります

日本語小説中心なら、むしろこちらの方が安全です。

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    if (!content.trim()) {

      return ''

    }

  

    return '\\n' + content + '\\n'

  }

}

もう一つ重要なのが br

現在、

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function (content, node, options) {

    return options.br + '\\n'

  }

}

となっています。

ここで options.br が何になっているか確認してください。

Turndownのデフォルトでは通常、

JavaScript

br: '  '

です。

つまり、

HTML

第一行<br\>第二行

がMarkdown向けには、

第一行  

第二行

になります。

青空文庫にするなら、

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function () {

    return '\\n'

  }

}

とした方がいいです。


私ならさらに「TurndownをMarkdown変換器として使わない」

今回のコードを見る限り、もうかなりTurndownを青空文庫コンバーターとして改造しています。

例えば、

JavaScript

rules.furigana

rules.kanji

rules.tcy

rules.sesame

rules.line

rules.heading

rules.blockquote

rules.list

rules.image

まで青空文庫用になっています。

この方向性自体はかなり良いのですが、Turndownには本来、

HTML → Markdown

という前提があります。

そのため、

HTML

 ↓

Turndown

 ↓

Markdown

ではなく、

HTML

 ↓

Turndownを利用した青空文庫変換

 ↓

青空文庫TXT

として、Markdown特有の後処理を極力排除するのがおすすめです。


特に確認してほしい場所

今回の「改行が削除される」問題は、提示された rules.js だけではなく、TurndownServiceを生成している側も見る必要があります。

例えば、

JavaScript

const turndownService \= new TurndownService({

  ...

})

と、

JavaScript

turndownService.addRule(...)

の部分です。

さらに、

JavaScript

turndownService.turndown(html)

後に何らかの .replace() をしていないかも重要です。

例えば、

JavaScript

result.replace(/\\n+/g, '\\n')

や、

JavaScript

result.replace(/\\s+/g, ' ')

があれば、これが改行を消している可能性があります。


そして今回の用途なら、かなり気になるのが blankReplacement

Turndownにはブロック要素の内容が空の場合に使われる処理があります。

例えばHTMLに、

HTML

<p\><br\></p\>

や、

HTML

<p\> </p\>

があると、

JavaScript

content.trim()

によって空と判断され、段落そのものが消えることがあります。

青空文庫変換では、空段落・改行だけの段落をどう扱うかは重要です。


もし今使っている new TurndownService({...}) から turndownService.turndown() までのコードも貼ってもらえれば、今回の rules.js と合わせて、「HTMLの改行を青空文庫の改行として確実に残す」ようにTurndown全体を修正する形まで具体的に直せます。

You:

ChatGPT:

ありがとうございます。これを見ると、原因がかなり絞れます。

rules.paragraph'\n' + content + '\n' 自体が改行を消しているわけではありません。
Turndown本体の processreplacementcollapseWhitespace 周辺で、ブロック要素の前後にある改行が整理されています。

特に今回のHTMLは、

HTML

<h1\>見出し1</h1\>

<h2\>見出し2</h2\>

<p\>ふりがな</p\>

<p\><ruby\>漢字<rt\>かんじ</rt\></ruby\></p\>

のようにHTMLソース自体にも改行が入っているため、TurndownがDOMを処理する際の空白正規化と、あなたの各replacementが競合します。

まず結論

青空文庫変換なら、paragraph を次のようにするのがよいです。

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    if (!content.trim()) {

      return ''

    }

  

    return content + '\\n'

  }

}

そして br は、

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function () {

    return '\\n'

  }

}

にしてください。

ただし、これだけでは今回のHTMLでは期待通りにならない可能性があります。


一番重要な問題

現在のHTMLにこれがあります。

HTML

<p\><strong\>太字</strong\><p\>

<span class\="em-sesame"\>傍点</span\></p\>

これはHTMLとして壊れています。

正しくは、

HTML

<p\><strong\>太字</strong\></p\>

<p\><span class\="em-sesame"\>傍点</span\></p\>

です。

ブラウザは壊れたHTMLを自動的に修正してDOMを作ります。

つまり、

HTML文字列

 ↓

ブラウザ/DOMParser

 ↓

ブラウザが補正したDOM

 ↓

Turndown

となります。

そのため、

HTML

<p\><strong\>太字</strong\><p\>

のようなHTMLを入力すると、入力したHTMLの改行とTurndownが見るDOMの構造が一致しないことがあります。

これはかなり重要です。


さらに今回のコードでは <p>&nbsp;</p> が問題になる

ここです。

HTML

<p\>&nbsp;</p\>

あなたの現在のルールでは、

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    return '\\n' + content + '\\n'

  }

}

ですが、Turndown側では &nbsp; が空白として処理される可能性があります。

そして、

JavaScript

if (!content.trim()) {

  return ''

}

にすると、この段落は消えます。

青空文庫で、

HTML

<p\>&nbsp;</p\>

を「空行」として扱いたいなら、別途処理した方がいいです。

例えば、

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content, node) {

    if (!content.trim()) {

      return '\\n'

    }

  

    return content + '\\n'

  }

}

とすると、

HTML

<p\>文章1</p\>

<p\>&nbsp;</p\>

<p\>文章2</p\>

文章1

  

文章2

にできます。


ただし、私はもう一段変更することをおすすめします

現在の目的は「HTML → Markdown」ではなく、

HTML → 青空文庫テキスト

ですよね。

その場合、

JavaScript

rules.paragraph

で無理に改行を制御するより、最後に青空文庫用の改行正規化を一度だけ行う方が扱いやすいです。

現在、

JavaScript

function update () {

  output.value \= turndownService.turndown(input.value)

}

なので、例えばこうします。

JavaScript

function update () {

  let result \= turndownService.turndown(input.value)

  

  // CRLFをLFに統一

  result \= result.replace(/\\r\\n/g, '\\n')

  

  // 3行以上の空行を2行までにする

  result \= result.replace(/\\n{3,}/g, '\\n\\n')

  

  // 前後の余計な改行を削除

  result \= result.trim()

  

  output.value \= result

}

この方式なら、

文章1

  

  

文章2

  

  

  

文章3

のような状態になった場合も、

文章1

  

文章2

  

文章3

まで整理できます。


ところで「改行が削除される」の意味が重要

例えばあなたが期待しているのが、

入力

HTML

<p\>これは文章です。<br\>

これは次の行です。</p\>

出力

これは文章です。

これは次の行です。

なら、

JavaScript

rules.lineBreak

の問題です。

これにしてください。

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function () {

    return '\\n'

  }

}

一方、

入力

HTML

<p\>第一段落です。</p\>

<p\>第二段落です。</p\>

期待

第一段落です。

第二段落です。

なら、

JavaScript

rules.paragraph

は、

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    return content + '\\n'

  }

}

で十分です。


さらに「HTML内の改行」を残したい場合

例えば、

HTML

<p\>

これは文章です。

これは次の文章です。

これは三行目です。

</p\>

を、

これは文章です。

これは次の文章です。

これは三行目です。

として出したい、という意味なら話が変わります。

TurndownはHTMLのソースコードの

HTML

<p\>

これは文章です。

これは次の文章です。

これは三行目です。

</p\>

というソース上の改行を、そのまま文章の改行として扱うものではありません

HTMLでは、

HTML

<p\>

これは文章です。

これは次の文章です。

</p\>

は基本的に、

これは文章です。これは次の文章です。

と同じ「1つの段落」です。

つまり、

HTMLのソースコード上の改行

<br>による表示上の改行

は別物です。

青空文庫変換では、この区別をした方がいいです。


今のプロジェクトなら、最終的にはこうするのがおすすめ

HTML

 │

 ├─ <p>       → 段落

 ├─ <br>      → 改行

 ├─ <ruby>    → |漢字《かんじ》

 ├─ <h1>      → [#大見出し]

 ├─ <img>     → [#画像…]

 ├─ <hr>      → [#改ページ]

 └─ span      → 青空文庫注記

 │

 ▼

Turndown

 │

 ▼

青空文庫TXT

 │

 ▼

改行正規化

そして paragraph は、

JavaScript

rules.paragraph \= {

  filter: 'p',

  

  replacement: function (content) {

    if (!content.trim()) {

      return '\\n'

    }

  

    return content + '\\n'

  }

}

br は、

JavaScript

rules.lineBreak \= {

  filter: 'br',

  

  replacement: function () {

    return '\\n'

  }

}

にするのが現状のコードには一番合っています。


もう一点、現在の ruby ルールにも問題があります

今は、

JavaScript

rules.furigana \= {

  filter: 'rt',

  

  replacement: function (content) {

    return '《' + content + '》'

  }

}

  

rules.kanji \= {

  filter: 'ruby',

  

  replacement: function (content) {

    return '|' + content

  }

}

なので、

HTML

<ruby\>漢字<rt\>かんじ</rt\></ruby\>

は、

|漢字《かんじ》

になるはずです。

これは青空文庫形式として正しい方向ですが、ルビが付いていない <ruby>、複数の <rt>rp を含むHTMLまで考えると、ここも少し修正した方がいいです。

今回の html2aozora はすでにかなり青空文庫専用になっているので、Turndownの標準Markdown処理をさらに削って、**「HTML DOMを青空文庫記法へ変換する専用ルールセット」**にしていくのが一番きれいだと思います。

You:

turndownを改造するか1から作るかどちらを進めるべきか

ChatGPT:

結論から言うと、今の html2aozora の状態なら「Turndownを改造して使い続ける」方をおすすめします。

ただし、単純にTurndownのルールを増やし続けるのではなく、Turndownを「HTML DOMの走査エンジン」として利用し、Markdown部分を捨てて青空文庫専用変換器に寄せるのがよいです。

比較

Turndown改造 1から作る
HTML解析 ◎ 既にある △ 自作が必要
DOM走査
<p>
<br>
<ruby>
<img>
<ul>/<ol>
ネストした要素
HTMLの壊れた構造への対応 ◎ ブラウザDOM依存
青空文庫独自仕様 ◎ ルール追加
Markdown由来の制約 △ 残る
保守性
開発量 少ない かなり多い

今回の目的なら、HTMLパーサーを自作するメリットがほとんどありません。


今のコードは「かなり良いところまで来ている」

提示してくれたルールを見ると、すでに、

<p>              → 段落

<br>             → 改行

<ruby>           → ルビ

<h1>~<h6>       → 見出し

<hr>             → 改ページ

<img>            → 画像注記

<ul>/<ol>        → 字下げ

<blockquote>     → 字下げ

<strong>         → 太字・傍点など

<em>             → 傍線など

<span class=tcy> → 縦中横

<span=em-sesame> → 傍点

<span=em-line>   → 傍線

までできています。

つまり現在困っているのは、

HTMLを解析できない

ではなく、

TurndownのMarkdown向けの挙動が青空文庫変換の邪魔をしている

という問題です。

これは「Turndownを捨てる理由」ではなく、Turndownの使い方を変える理由だと思います。


おすすめは「TurndownベースのAozora Converter」にする

例えば構造をこうします。

html2aozora

│

├─ turndown/

│   ├─ turndown.js

│   ├─ utilities.js

│   └─ ...

│

├─ rules/

│   ├─ paragraph.js

│   ├─ ruby.js

│   ├─ heading.js

│   ├─ image.js

│   ├─ emphasis.js

│   └─ ...

│

├─ aozora.js

└─ index.html

そして、

JavaScript

const service \= new TurndownService({

    ...

})

  

service.addRule(...)

というTurndownの仕組みだけ利用します。

Markdownを生成することは目的にしない。


さらに一歩進めるなら「AozoraService」にする

例えば、

JavaScript

class AozoraService extends TurndownService {

    ...

}

とする方法もあります。

あるいは継承せず、

JavaScript

const turndownService \= new TurndownService(options)

  

turndownService.addRule(...)

として、

JavaScript

function htmlToAozora(html, options) {

    const service \= createAozoraService(options)

    return service.turndown(html)

}

というAPIにします。

こうすれば利用側は、

JavaScript

const text \= htmlToAozora(html, {

    headingTop: 'h1',

    bulletListMarker: '● '

})

だけで済みます。


ただし「Turndown本体の改造」は最小限にする

ここは重要です。

現在、

Turndown

 ↓

独自rules.js

となっていると思いますが、

Turndown本体を大量改造

 ↓

独自rules.js

にはしない方がいいです。

理由は、Turndownを更新できなくなるからです。

できれば、

Turndown本体

   ↓

HTML DOMを取得

   ↓

ルールによる変換

   ↓

青空文庫

の「ルール部分」だけ変更します。


ただし、将来的に1から作る選択肢はある

実は今回のプロジェクトを長期的に考えると、

「Turndownを使わないAozora DOM Converter」

に移行する可能性はあります。

例えば、

JavaScript

function convertNode(node) {

    switch (node.nodeName) {

        case 'P':

            return convertParagraph(node)

  

        case 'BR':

            return '\\n'

  

        case 'RUBY':

            return convertRuby(node)

  

        case 'H1':

            return convertHeading(node)

  

        case 'IMG':

            return convertImage(node)

  

        default:

            return convertChildren(node)

    }

}

というものです。

これは実はかなりシンプルです。

そして、

JavaScript

function convertChildren(node) {

    let result \= ''

  

    for (const child of node.childNodes) {

        result += convertNode(child)

    }

  

    return result

}

とすれば、DOMツリーを再帰的に走査できます。


1から作るメリット

最大のメリットは、

改行を完全に自分で管理できる

ことです。

例えば、

HTML

<p\>文章1</p\>

<p\>文章2</p\>

なら、

文章1

文章2
HTML

<p\>文章1<br\>文章2</p\>

なら、

文章1

文章2
HTML

<p\>文章1</p\>

<p\></p\>

<p\>文章2</p\>

なら、

文章1

  

文章2

と明確に定義できます。

Turndownの、

Markdownとしてどう処理するか

という思想に引きずられません。


それでも今すぐ1から作らない方がいい

理由は、HTMLの世界は思ったより面倒だからです。

例えば、

HTML

<p\>

  <strong\>太字の

    <em\>文章</em\>

  </strong\>

  です。

</p\>

や、

HTML

<p\>

  <ruby\>

    漢字

    <rp\>(</rp\>

    <rt\>かんじ</rt\>

    <rp\>)</rp\>

  </ruby\>

</p\>

などを正しく処理する必要があります。

さらに、

HTML

<table\>

<div\>

<ul\>

<ol\>

<li\>

<blockquote\>

<pre\>

<code\>

<a\>

<img\>

などが絡んできます。

HTMLパーサーを自作する必要はありませんが、DOMから意味を取り出す処理はかなり増えます。

だから、

HTMLをDOMにする部分 → 既存ライブラリ
DOMから青空文庫にする部分 → 自作

というのが理想です。


私ならこう進める

第1段階:現在のTurndown版を完成させる

まず、

HTML

 ↓

Turndown

 ↓

青空文庫TXT

を完成させます。

特に、

  • 改行

  • 段落

  • ルビ

  • 見出し

  • 字下げ

  • 傍点

  • 傍線

  • 縦中横

  • 画像

  • 改ページ

  • リンク

  • リスト

を固めます。


第2段階:テストHTMLを大量に作る

例えば、

tests/

├─ paragraph.html

├─ linebreak.html

├─ ruby.html

├─ heading.html

├─ emphasis.html

├─ image.html

├─ list.html

├─ nested.html

└─ malformed.html

そして、

入力HTML

    ↓

期待する青空文庫TXT

をテストします。

ここまでやると、Turndown由来の問題がかなり明確になります。


第3段階:Turndownが邪魔になってきたら独自DOM Converterへ

その時点で、

Turndown

 ├─ Markdown用処理

 ├─ whitespace処理

 ├─ block処理

 └─ 独自青空文庫処理

という構造が限界になってきたら、

DOMParser

   ↓

AozoraConverter

   ↓

青空文庫TXT

に置き換えます。

ルール自体はかなり再利用できます。


なので結論

今の html2aozora なら、

今はTurndownを改造して進める。

が一番いいです。

ただし、

TurndownをMarkdown変換器として改造するのではなく、HTML DOMの走査・ルールシステムとして利用する。

という方向がおすすめです。

そして将来的には、

現在

                ↓

HTML → Turndown → 青空文庫

                ↓

          ルールを成熟させる

                ↓

HTML → DOMParser → AozoraConverter

という移行ができます。

特にあなたの場合、最終的にAozoraEpub3へ渡す青空文庫TXTを作ることが目的なので、最終形としては「Markdown変換器」ではなく**「HTML → 青空文庫変換器」専用の実装**にするのが一番きれいです。

You:

tinymceで貼り付けたり編集されたHTMLを青空文庫として出力するには

ChatGPT:

できます。むしろ TinyMCEを編集UI、青空文庫変換器を出力エンジンとして分離する構成がかなり相性がいいです。

ユーザー

  ↓

TinyMCE

  ↓

HTMLを編集

  ↓

editor.getContent()

  ↓

HTML → 青空文庫変換器

  ↓

青空文庫TXT

  ↓

AozoraEpub3

  ↓

EPUB

この用途なら、私は TinyMCE + Turndown改造版から始めるより、最終的にはDOMベースの青空文庫変換器にすることをおすすめします。

TinyMCEを使う場合の重要なポイント

TinyMCEから取得するHTMLは、例えば、

HTML

<p\>これは本文です。</p\>

<p\>次の段落です。</p\>

<p\>これは<ruby\>漢字<rt\>かんじ</rt\></ruby\>です。</p\>

のようになります。

ここで重要なのは、

TinyMCEのHTMLをMarkdownに変換する必要はありません。

そのままDOMとして解析します。

JavaScript

const html \= tinymce.activeEditor.getContent();

  

const parser \= new DOMParser();

const doc \= parser.parseFromString(html, 'text/html');

  

const aozora \= convertDocument(doc.body);

この構成なら改行問題も解決しやすい

例えば、

HTML

<p\>第一段落</p\>

<p\>第二段落</p\>

なら、

JavaScript

function convertParagraph(node) {

    return convertChildren(node) + '\\n';

}

として、

第一段落

第二段落

にできます。

一方、

HTML

<p\>第一行<br\>第二行</p\>

なら、

JavaScript

function convertLineBreak() {

    return '\\n';

}

として、

第一行

第二行

にできます。

つまり、

HTMLソース上の改行ではなく、DOMの意味を見て改行を決められます。

これはTinyMCEとの組み合わせでは非常に大きなメリットです。


TinyMCEで青空文庫用の編集機能も作れる

さらに面白いのは、TinyMCE側で青空文庫に対応した編集UIを作れることです。

例えば、

\[太字\]

\[傍点\]

\[傍線\]

\[ルビ\]

\[見出し\]

\[縦中横\]

\[画像\]

\[改ページ\]

などのボタンを用意できます。

ユーザーが、

これは重要な文章です

を選択して「傍点」を押すと、

HTML

<span class\="em-sesame"\>これは重要な文章です</span\>

にする。

青空文庫出力時に、

HTML

<span class\="em-sesame"\>

  これは重要な文章です

</span\>

[#傍点]これは重要な文章です[#傍点終わり]

と変換します。

これなら青空文庫記法をユーザーに直接入力させなくて済みます。


ルビもTinyMCE側で扱える

例えばTinyMCE内部では、

HTML

<ruby\>漢字<rt\>かんじ</rt\></ruby\>

を保持します。

出力時には、

|漢字《かんじ》

へ変換。

これも非常に自然です。

さらに、

HTML

<ruby\>

  漢字

  <rp\>(</rp\>

  <rt\>かんじ</rt\>

  <rp\>)</rp\>

</ruby\>

なら、

|漢字《かんじ》

にできます。

rp は出力時には捨てればいいので、今作っているルールとも相性がいいです。


画像もTinyMCEに任せられる

TinyMCEで画像を挿入すると、

HTML

<img src\="images/001.jpg" alt\="挿絵"\>

のようなHTMLになります。

青空文庫変換器では、

JavaScript

function convertImage(node) {

    const src \= node.getAttribute('src') || '';

    const alt \= node.getAttribute('alt') || '';

  

    return \`[#${alt}(${src})入る]\`;

}

として、

[#挿絵(images/001.jpg)入る]

にできます。


さらに重要なのが「TinyMCE用HTML」と「青空文庫」を分離すること

私はこの構成をおすすめします。

┌─ TinyMCE HTML

                    │

編集UI ─────────────┤

                    │

                    └─ HTML保存

                          │

                          ▼

                 AozoraConverter

                          │

             ┌────────────┼────────────┐

             ▼            ▼            ▼

         青空文庫TXT     EPUB用XHTML   プレビュー

             │

             ▼

         AozoraEpub3

つまり、TinyMCEのHTMLそのものを青空文庫形式にする必要はありません。

TinyMCEでは、

「編集しやすいHTML」

を保存しておき、

変換時だけ、

「青空文庫記法」

へ変換します。


そして今のhtml2aozoraをそのまま発展させられる

今の、

rules.paragraph

rules.lineBreak

rules.furigana

rules.kanji

rules.tcy

rules.sesame

rules.line

rules.heading

rules.blockquote

rules.list

rules.image

は、そのまま変換ルールの設計資料になります。

Turndownを使う場合:

HTML

 ↓

Turndown

 ↓

rules

 ↓

青空文庫

新しい方式:

TinyMCE HTML

 ↓

DOMParser

 ↓

convertNode()

 ↓

青空文庫

になります。

例えば、

JavaScript

function convertNode(node) {

    if (node.nodeType \=== Node.TEXT\_NODE) {

        return node.nodeValue;

    }

  

    switch (node.nodeName) {

        case 'P':

            return convertParagraph(node);

  

        case 'BR':

            return '\\n';

  

        case 'RUBY':

            return convertRuby(node);

  

        case 'RT':

            return convertRubyText(node);

  

        case 'H1':

        case 'H2':

        case 'H3':

        case 'H4':

        case 'H5':

        case 'H6':

            return convertHeading(node);

  

        case 'IMG':

            return convertImage(node);

  

        case 'HR':

            return '[#改ページ]\\n';

  

        default:

            return convertChildren(node);

    }

}

という形です。


TinyMCEを使うなら「独自タグ」も検討するといい

例えば縦中横なら、

HTML

<span class\="tcy"\>12</span\>

でもいいですが、

HTML

<span data-aozora\="tcy"\>12</span\>

のようにする方法もあります。

傍点なら、

HTML

<span data-aozora\="sesame"\>文字</span\>

傍線なら、

HTML

<span data-aozora\="line"\>文字</span\>

とします。

そうするとCSSと変換処理を明確に分離できます。

data-aozora="sesame"

       │

       ├─ TinyMCE表示 → CSS

       │

       └─ 青空文庫出力 → [#傍点]

これはかなりおすすめです。


私なら最終的にこうします

┌─────────────────────┐

│       TinyMCE       │

│                     │

│  本文編集           │

│  ルビ               │

│  傍点               │

│  傍線               │

│  見出し             │

│  画像               │

│  改ページ           │

└─────────┬───────────┘

          │ getContent()

          ▼

┌─────────────────────┐

│  AozoraConverter    │

│                     │

│  DOM → 青空文庫     │

└─────────┬───────────┘

          │

          ▼

┌─────────────────────┐

│ 青空文庫TXT         │

└─────────┬───────────┘

          │

          ▼

     AozoraEpub3

          │

          ▼

         EPUB

TinyMCEを導入するなら、ここでTurndownを完全に捨ててDOMベースに移行するのはかなり合理的です。

理由は、TinyMCEがすでに「HTML DOMを編集するための環境」を提供してくれるからです。Turndownを経由する意味がほぼなくなります。

特に今の html2aozora のルールをベースにすれば、AozoraConverter を作る作業は「1から全部作り直す」というより、現在のrulesをDOM変換関数へ移植する作業になります。

You:

tinymceで空白行を作るとスペースが入る

ChatGPT:

はい。TinyMCEではこれはかなり典型的な挙動です。

例えば空白行を作るためにEnterを押すと、内部HTMLが単純な空の<p></p>ではなく、

HTML

<p\>&nbsp;</p\>

HTML

<p\><br\></p\>

になることがあります。

青空文庫に変換するなら、TinyMCE側で無理に空白を消すより、出力時に「空の段落」を空行として扱うのがおすすめです。

例えば変換器側

JavaScript

function convertParagraph(node) {

  const content \= convertChildren(node);

  

  // 空白・改行・&nbsp;だけの段落

  if (!content.replace(/\\u00a0/g, ' ').trim()) {

    return '\\n';

  }

  

  return content + '\\n';

}

これなら、

HTML

<p\>本文1</p\>

<p\>&nbsp;</p\>

<p\>本文2</p\>

が、

本文1

  

本文2

になります。


さらにTinyMCEから取得した直後に正規化する方法

getContent()の段階で、

JavaScript

let html \= tinymce.activeEditor.getContent();

  

html \= html.replace(

  /<p>(?:&nbsp;|\\u00a0|<br\\s\*\\/?>|\\s)\*<\\/p>/gi,

  '<p></p>'

);

としてもいいです。

ただし、私はこちらはあまりおすすめしません

TinyMCEのHTMLを編集用データとして保存するなら、

TinyMCE

 ↓

そのままHTML保存

 ↓

AozoraConverter

 ↓

空段落を青空文庫の空行に変換

の方が役割分担がきれいです。


「スペースが入る」のが青空文庫出力側なら

例えば現在の変換結果が、

本文1

本文2

のように、空白行に全角スペースや半角スペースが入ってしまうなら、変換器のテキストノード処理で、

JavaScript

node.nodeValue

をそのまま出している可能性があります。

この場合は、通常の文章中のスペースは残しつつ、空段落だけ判定する必要があります。

例えば、

JavaScript

function convertParagraph(node) {

  const content \= convertChildren(node);

  

  const normalized \= content

    .replace(/\\u00a0/g, '')

    .replace(/\[ \\t\\r\\n\]/g, '');

  

  if (!normalized) {

    return '\\n';

  }

  

  return content + '\\n';

}

これなら、

HTML

<p\>&nbsp;</p\>

\\n

になります。

一方、

HTML

<p\>これは 文章です</p\>

の半角スペースは維持できます。


TinyMCE + 青空文庫なら、この扱いがおすすめ

<p>文章</p>

        ↓

文章\\n

  

<p>&nbsp;</p>

        ↓

\\n

  

<p><br></p>

        ↓

\\n

  

<p>次の文章</p>

        ↓

次の文章\\n

つまり、TinyMCE内部の&nbsp;を「空白文字」として青空文庫へ持ち込まず、「空段落を表現するための内部的な記号」として扱うのがいいです。

もし現在のTinyMCEの初期化コード(tinymce.init({...}))も見せてもらえれば、&nbsp;を入れない設定にできるかも含めて、TinyMCE側とAozoraConverter側のどちらで処理するのが最適か具体的に整理できます。

You:

URLを入力するとaタグが追加される

ChatGPT:

はい。これは TinyMCEのAuto Link機能が原因です。

TinyMCEでは、URLを入力してスペースやEnterなどで確定すると、自動的に

HTML

<a href\="https://example.com"\>https://example.com</a\>

へ変換されます。

青空文庫用エディタなら、URLを単なる本文として扱いたい場合はAuto Linkを無効化するのが一番簡単です。

TinyMCE 6/7系なら

JavaScript

tinymce.init({

  selector: '#editor',

  

  plugins: 'autolink',

  

  ...

})

のように autolink を入れているなら削除します。

ただし、TinyMCEの設定によっては link プラグインとは別にAuto Linkが有効になっている場合があります。

例えば、

JavaScript

tinymce.init({

  selector: '#editor',

  plugins: 'autolink link',

})

なら、

JavaScript

tinymce.init({

  selector: '#editor',

  plugins: 'link',

})

にします。


URLもリンクとして扱いたい場合

逆に、

https://example.com

を入力したら、

HTML

<a href\="https://example.com"\>https://example.com</a\>

にしたいのであれば、TinyMCE側はそのままで構いません。

そして青空文庫変換時に、

HTML

<a href\="https://example.com"\>https://example.com</a\>

をどう出力するか決めます。

あなたの現在のルールでは、

JavaScript

rules.inlineLink \= {

  filter: function (node, options) {

    return (

      node.nodeName \=== 'A' &&

      node.getAttribute('href')

    )

  },

  

  replacement: function (content, node) {

    var href \= node.getAttribute('href')

    var title \= cleanAttribute(node.getAttribute('title'))

    if (title) title \= ' title="' + title + '" '

  

    return '<a href="' + href + '"' + title + '>' + content + '</a>'

  }

}

なので、これは青空文庫TXTとしては少し特殊です。

青空文庫の本文にHTMLの<a>を残すのではなく、例えばURLそのものを残すなら、

JavaScript

rules.inlineLink \= {

  filter: 'a',

  

  replacement: function (content, node) {

    return content

  }

}

とすれば、

HTML

<a href\="https://example.com"\>https://example.com</a\>

https://example.com

になります。


もし「リンク文字列」と「リンク先」を両方残したいなら

例えば、

HTML

<a href\="https://example.com"\>公式サイト</a\>

を青空文庫側で何らかの注記として保存したい場合は、専用のルールを決める方がいいです。

例えば独自形式として、

公式サイト[#リンク(https://example.com)]

など。


今回のTinyMCE + 青空文庫なら

私は次の方針をおすすめします。

URLを直接入力

    ↓

TinyMCE

    ↓

自動リンク化されてもOK

    ↓

<a href="URL">URL</a>

    ↓

AozoraConverter

    ↓

URL

つまり、TinyMCE内部のHTMLは編集用データとして自由にして、青空文庫変換時に<a>をどうするか決める方式です。

この方が、後から「URLだけ出力」「リンク注記として出力」「HTMLリンクとして保持」などを選べます。

You:

tinymceとaozoraepub3jsを組み合わせてweb上でEPUB出力するには

ChatGPT:

できます。しかも、これまで話していた TinyMCE → HTML → 青空文庫変換をそのまま発展させれば、ブラウザだけで EPUBを生成してダウンロードする構成にできます。

ただし重要なのは、AozoraEpub3-JSを「Java版AozoraEpub3の完全なブラウザ移植」として考えるのではなく、ブラウザで動くEPUB生成ライブラリとして組み込むことです。

https://images.openai.com/static-rsc-4/bbRUchSC0jFM2y0E7-f6an-PpG8vYsbgvVNQrPDc1OTopdhOD5ZffAGVj3U3qevEolh-rS8VrZTf4SR44-hWtLBdYbyiCCaAaLK6kLRckNo9VpAA-IlKX_B_QhlaSmNILPti90G_oX7eNqiFeoHBinM3A38tbqa1yMwhfWpE31z51xvq0zlOx5Rp7iFanIO1?purpose=fullsize

https://images.openai.com/static-rsc-4/5JVgi-RglbopKhZ1JNz44SVaywXKDykunYSE9NTjnYyA2xDJlgIh0s3HmnIpUHyMmXt4zH_OjOEsfqlWUlLbjFKuVDNjxgAdKfJuCx91UOKW2sV7Ov8X4802Koyl0n-7WtdQKlMkAG0vEh83IoPTO1FLDU0mkBmkfPqFt2RsM0z7h0ALujIdhnwFgVeJ5cP2?purpose=fullsize

https://images.openai.com/static-rsc-4/hyxIFM8fikfldwtUBrdh_AxCYcghANVMaDhxoSVXdW_2bAmBKo527RJNBjXhh2E3k4QCIsXWVLuNk-RQQUfNp3eChlQOqV0yR7qg3KkkkmOtBJF7BOzEpSx871FD_PzRjDjrDIbfBtGmarJnHsHU1PZoEBSZSXVmliViFCIxgQ1OAbXolqBYFetGLWHHECmO?purpose=fullsize

5

おすすめ構成

┌─────────────────────┐

│       TinyMCE       │

│                     │

│  本文を入力・編集   │

│  ルビ               │

│  傍点               │

│  傍線               │

│  見出し             │

│  画像               │

│  改ページ           │

└──────────┬──────────┘

           │

           │ editor.getContent()

           ▼

┌─────────────────────┐

│  AozoraConverter    │

│                     │

│ HTML DOM            │

│       ↓             │

│ 青空文庫形式        │

└──────────┬──────────┘

           │

           ▼

┌─────────────────────┐

│   AozoraEpub3JS     │

│                     │

│ XHTML               │

│ CSS                 │

│ OPF                 │

│ container.xml       │

│ nav.xhtml           │

└──────────┬──────────┘

           │

           ▼

       EPUB 3

           │

TinyMCEからHTMLを取得するには editor.getContent() が使えます。TinyMCE自身も、このAPIで通常HTMLとして編集内容を取得する方式を提供しています。TinyMCE+1


ただし、ここで一つ変更した方がいい

これまでの話では、

TinyMCE

 ↓

HTML

 ↓

青空文庫TXT

 ↓

AozoraEpub3

 ↓

EPUB

を想定していました。

Web版では、

TinyMCE

 ↓

HTML

 ↓

青空文庫変換

 ↓

AozoraEpub3JS

 ↓

EPUB

にできますが、青空文庫TXTを一度文字列として作って、それを再解析する必要はありません。

むしろ、

TinyMCE HTML

       ↓

Document Model

       ↓

       ├── 青空文庫TXT

       │

       └── EPUB XHTML

という構造にした方が将来的に強いです。


つまり「中間データ」を作る

例えばTinyMCEに、

HTML

<p\>これは<strong\>重要</strong\>な文章です。</p\>

<p\><ruby\>漢字<rt\>かんじ</rt\></ruby\>です。</p\>

が入っていたとします。

これを直接、

HTML → EPUB

とするのではなく、

HTML

 ↓

AozoraDocument

に変換します。

例えば内部的には、

JavaScript

{

  type: "paragraph",

  children: \[

    {

      type: "text",

      value: "これは"

    },

    {

      type: "bold",

      children: \[

        {

          type: "text",

          value: "重要"

        }

      \]

    },

    {

      type: "text",

      value: "な文章です。"

    }

  \]

}

のような構造です。

ルビなら、

JavaScript

{

  type: "ruby",

  text: "漢字",

  ruby: "かんじ"

}

とします。


そうすると出力を2種類作れる

同じ内部データから、

青空文庫TXT

これは[#太字]重要[#太字終わり]な文章です。

  

|漢字《かんじ》です。

EPUB XHTML

HTML

<p\>これは<span class\="bold"\>重要</span\>な文章です。</p\>

  

<p\><ruby\>漢字<rt\>かんじ</rt\></ruby\>です。</p\>

という2種類を生成できます。

これがかなり重要です。


AozoraEpub3JSの位置づけ

ここで、あなたが開発している AozoraEpub3JS を使います。

現在のAozoraEpub3は、青空文庫注記入りテキストをEPUB 3へ変換するものです。Java版では青空文庫TXT/ZIPからEPUB 3を生成する仕組みになっています。GitHub

Web版では、

AozoraDocument

       ↓

EPUB Builder

       ↓

EPUB ZIP

という部分を担当させるのがいいです。

つまり、

TinyMCE

  ↓

HTML

  ↓

AozoraConverter

  ↓

AozoraEpub3JS

という責務分離です。


ブラウザでEPUBを作る場合

EPUBは基本的には、

book.epub

│

├─ mimetype

├─ META-INF/

│   └─ container.xml

│

└─ OEBPS/

    ├─ package.opf

    ├─ nav.xhtml

    ├─ style.css

    ├─ text/

    │   ├─ chapter001.xhtml

    │   └─ chapter002.xhtml

    └─ image/

        ├─ image001.jpg

        └─ image002.png

というZIPです。

したがって、

XHTML生成

+

CSS生成

+

OPF生成

+

nav.xhtml生成

+

画像

+

ZIP化

ができれば、ブラウザだけでEPUBを作れます。

ブラウザ上でEPUBを扱うJavaScriptライブラリの例としてepub.jsなどもありますが、これは主にEPUBの表示・レンダリング用なので、今回の「EPUB生成」部分とは分けて考えるのがいいです。GitHub


画像が一番注意点

TinyMCEに画像を貼り付けた場合、

HTML

<img src\="data:image/png;base64,..."\>

になる場合があります。

あるいは、

HTML

<img src\="https://example.com/image.jpg"\>

の場合もあります。

EPUBにするなら、

TinyMCE

 ↓

<img>

 ↓

画像データ取得

 ↓

EPUB内に配置

 ↓

XHTMLから相対パス参照

にする必要があります。

例えば、

HTML

<img src\="images/image001.png"\>

にして、

OEBPS/images/image001.png

をZIPへ入れます。


TinyMCEの画像貼り付けも活用できる

例えばユーザーがPCから画像を貼り付ける。

Ctrl + V

 ↓

TinyMCE

 ↓

画像

 ↓

HTML

そしてEPUB出力時に、

data:image/png;base64,...

 ↓

Uint8Array

 ↓

EPUB ZIP

とすれば、サーバーに画像をアップロードしなくても完全クライアントサイドでEPUBを作れます

これはWebアプリとしてかなり面白い構成です。


さらに「リアルタイムEPUBプレビュー」も可能

例えば画面を、

┌─────────────────────────────┐

│ TinyMCE                     │

│                             │

│ 本文を編集                  │

│                             │

└─────────────────────────────┘

  

        \[青空文庫プレビュー\]

        \[EPUB出力\]

  

┌─────────────────────────────┐

│ EPUB Preview                │

│                             │

│       縦書き本文            │

│                             │

└─────────────────────────────┘

とできます。

EPUBのHTML/CSSを生成して、それをブラウザ内でプレビューします。

epub.jsのようなEPUBレンダラーは、ブラウザ内でEPUBを表示する用途に使えます。GitHub


AozoraEpub3JSをこう分割するとかなり良い

今後の設計としては、

AozoraEpub3JS/

│

├── aozora/

│   ├── parser.js

│   ├── annotation.js

│   └── document.js

│

├── html/

│   ├── converter.js

│   └── renderer.js

│

├── epub/

│   ├── EpubBuilder.js

│   ├── Container.js

│   ├── Package.js

│   ├── Navigation.js

│   └── ImageManager.js

│

├── css/

│   └── style.js

│

└── index.js

という構成にします。

そして、

JavaScript

import { AozoraConverter } from './aozora/converter.js'

import { EpubBuilder } from './epub/EpubBuilder.js'

  

const html \= tinymce.activeEditor.getContent()

  

const document \= AozoraConverter.fromHtml(html)

  

const epub \= await EpubBuilder.build(document)

  

download(epub, 'novel.epub')

という使い方にします。


「青空文庫TXT」も同時に出せる

これがこの設計の大きなメリットです。

TinyMCE

                     │

                     ▼

              AozoraDocument

                │         │

                │         │

                ▼         ▼

        AozoraText    EpubBuilder

             │             │

             ▼             ▼

        novel.txt      novel.epub

つまり、

「青空文庫TXTを作る機能」と「EPUBを作る機能」を別々に作らなくて済みます。

さらにAozoraEpub3JSの既存の青空文庫TXT変換機能も残せます。


今のプロジェクトなら、この順番が一番いい

  1. TinyMCE

    • 編集

    • 貼り付け

    • ルビ

    • 傍点

    • 傍線

    • 見出し

    • 画像

  2. HTML → AozoraDocument

    • 今作っている rules の処理を移植
  3. AozoraDocument → 青空文庫TXT

    • 今の html2aozora 相当
  4. AozoraDocument → EPUB XHTML

    • EPUB用HTML生成
  5. EPUB Builder

    • OPF

    • nav.xhtml

    • CSS

    • 画像

    • ZIP

  6. Blob化してブラウザから保存

という順番です。

特に、AozoraDocumentを中間層として設けるのがポイントです。

これなら将来的に、

TinyMCE

   ↓

HTML

   ↓

AozoraDocument

   ├── 青空文庫TXT

   ├── EPUB

   ├── HTMLプレビュー

   └── Markdown

まで発展できます。

また、現在進めているAozoraEpub3JSがすでにJava版AozoraEpub3の変換処理を移植したものなら、EPUB生成部分を新しく作り直すより、既存の「青空文庫テキスト → XHTML/OPF/ZIP」部分をブラウザ向けAPIとして切り出すのが最も効率的だと思います。

7月30日から8月5日までキンドル新刊ピックアップ

7月30日発売の本

7月30日発売のそのほかの本 kyukyunyorituryo.github.io

7月31日発売の本

7月31日発売のそのほかの本 kyukyunyorituryo.github.io

8月1日発売の本

8月1日発売のそのほかの本 kyukyunyorituryo.github.io

8月2日発売の本

8月2日発売のそのほかの本 kyukyunyorituryo.github.io

8月3日発売の本

8月3日発売のそのほかの本 kyukyunyorituryo.github.io

8月4日発売の本

8月4日発売のそのほかの本 kyukyunyorituryo.github.io

8月5日発売の本

8月5日発売のそのほかの本 kyukyunyorituryo.github.io

AozoraEpub3が増殖中

Java版は改造してnode.js版とAndroid版を作ってますがrustやdotnet等があるみたい。あんまり人のコードを見ていないのでどれくらいの個性があるのかがよくわからないのだが。

電書連EPUBに近づけてKADOKAWA EPUBと各ストア向けに修正して販売可能なEPUBにするのが目的としている。UIを大きく変えるのも操作成果が変わりそうでやめている。ライブラリの更新とバグ取りがメインになっている。

最近はXTEINKに向けてEPUBを生成するのに使われている方も見受けられる。ただ、昔のソニーリーダーよりも動作要件は厳しそうだ。日本に上陸した初期のキンドルのE Ink端末が7000円ぐらいだったのでそれぐらいの価格のものが人気を集めているのだろうか。

IntelliJとAndroid Studioが利用している容量が大きかったので減らした

Android Studio

Android Studioのアプリを全部更新するとCドライブが20GB消費したので減らした。android studioだと古いavdやsdk、gradleキャッシュなんかが溜まって容量を圧迫していた。諸々のキャッシュをクリアした。

 SSDが高い昨今では大容量のSSDに交換するのを先延ばしにしたい気持ちが強い。

geminiの回答

Android StudioによるPCのストレージ圧迫を解消するには、不要な仮想デバイス(AVD)やシステムイメージ、Gradleキャッシュを削除するのが最も効果的です。数GBから数十GB単位で空き容量を大幅に増やすことができます。

効果が高い順に対処法をまとめました。

1. 仮想デバイス(AVD)の削除・初期化

エミュレータ(仮想デバイス)は1台につき数GB〜十数GBの容量を消費します。

  • 使っていないエミュレータを削除する:
  • 画面右上の「Device Manager」を開きます。
    • 使っていないデバイスの右側にあるメニュー(縦の3点リーダー)から 「Delete」 を選択します。 [1, 2, 3]
  • エミュレータのデータを初期化(Wipe Data)する:
  • 使い続けるデバイスでも、キャッシュやアプリデータが肥大化している場合があります。
    • Device Managerで対象デバイスのメニューから 「Wipe Data」 を実行すると、初期状態に戻り容量が削減されます。

2. 不要なシステムイメージの削除

Android StudioのSDK Managerから過去の古いOSバージョン(APIレベル)をダウンロードした場合、その「System Image」がストレージに残り続けます。

  • 削除手順:
  • Tools > SDK Manager を開きます。
    1. 「Android SDK」タブ内の 「SDK Platforms」 を選択します。
    2. 右下の 「Show Package Details」 にチェックを入れます。
    3. 使っていない古いOSバージョンの「System Image」(例: Google Play Intel x86 Atom System Image など)のチェックを外します。
    4. 「Apply」をクリックすると、不要なイメージファイルが削除されます。

💡 手動で直接削除する場合(Mac環境の例) ~/Library/Android/sdk/system-images の中身を直接確認して手動削除することも可能です。

3. Gradleキャッシュのクリーンアップ

ビルドの高速化のために蓄積される「Gradleキャッシュ」は、放置すると数十GBに膨れ上がることがあります。

  • Android Studio内での削除:
  • メニューの File > Invalidate Caches... を選択します。
    • すべてのチェックボックス(特に「Clear file system cache and Local History」など)にチェックを入れて 「Invalidate and Restart」 を実行します。
  • フォルダ全体の直接削除(さらに強力):
  • Android Studioを一度完全に終了します。
    • ホームディレクトリにある .gradle フォルダ内の「caches」フォルダを丸ごと削除します。
    • Windows: C:\Users\ユーザー名.gradle\caches
      • Mac: ~/.gradle/caches
    • ※次回プロジェクトを開いた際のビルド(同期)に少し時間がかかりますが、必要なキャッシュだけが再ダウンロードされるため安全です。

4. 過去のビルド生成物(buildフォルダ)の削除

開発中のプロジェクトフォルダ内にある「build」フォルダも容量を圧迫します。

  • 長期間触っていないプロジェクトがある場合、Android Studioでそのプロジェクトを開き、メニューの Build > Clean Project を実行してください。
  • しばらく起動する予定のない古いプロジェクトは、プロジェクトのフォルダごと外部ストレージやクラウドに退避させるか、ジップファイル等に圧縮することをおすすめします。 [4]

PCのOS(Windows または Mac)はどちらでしょうか? また、現在ストレージが何GBほど圧迫されているか教えていただければ、より最適なフォルダの特定や一括削除のコマンドをご案内できます。

[1] https://qiita.com [2] https://pokuwagata.hatenablog.com [3] https://codeforfun.jp [4] https://detail.chiebukuro.yahoo.co.jp

IntelliJ

IntelliJがなんかいっぱい容量を食っていたので古いバージョンを削除すると4GBと減った。

toolboxから「設定-ツール-以前のバージョンを削除」で4GBが削除された。

geminiの回答

IntelliJ IDEAでストレージ容量を増やす場合も、Android Studioとほぼ同様の仕組みでキャッシュやビルド生成物、プラグインを削除するのが効果的です。

特にJava/Kotlinやビルドツール(Gradle/Maven)のデータが数GB〜数十GB溜まっていることが多いため、以下の手順でクリーンアップを行ってください。

1. IntelliJのシステムキャッシュを削除する

IntelliJがインデックス(高速検索用のデータ)やキャッシュとして保持している一時ファイルを削除します。

  • 手順:
  • 上部メニューの File > Invalidate Caches... を選択します。
    1. 表示されたダイアログで、必要に応じて追加のチェックボックス(ローカル履歴の削除など)にチェックを入れます。
    2. 「Invalidate and Restart」 をクリックします。
  • 効果: 肥大化した古いインデックスが一掃され、数GBの空き容量が確保できます(再起動後に再インデックスが走るため、一時的に動作が重くなります)。

2. Gradle / Maven のキャッシュを削除する(強力)

プロジェクトでGradleやMavenを使っている場合、過去の古いライブラリ(依存関係)がすべてローカルストレージに保存され続けています。

  • Gradleの場合:
  • IntelliJを完全に終了します。
    • 以下の「caches」フォルダをフォルダごと削除します。
    • Windows: C:\Users\ユーザー名.gradle\caches
      • Mac: ~/.gradle/caches
    • Mavenの場合:
  • IntelliJを完全に終了します。
    • 以下の「repository」フォルダ(ダウンロードされたライブラリ群)の中身を削除します。
    • Windows: C:\Users\ユーザー名.m2\repository
      • Mac: ~/.m2/repository
    • ※削除後にIntelliJでプロジェクトを開くと、現在必要なライブラリだけが自動で再ダウンロードされます。

3. 使っていないプラグインの削除

過去に入れたまま使っていないプラグインや、デフォルトで有効になっている不要なプラグインを無効化・削除することで、容量とメモリ(動作の軽さ)を節約できます。

  • 手順:
  • Settings(Macは Preferences)> Plugins を開きます。
    1. 「Installed」タブを選択します。
    2. 使用していないプラグイン(例:使っていない言語のサポート、フレームワークのプラグインなど)のチェックを外すか、アンインストールします。

4. プロジェクトごとの「out」「target」「.idea」のクリーン

長期間触っていない古いプロジェクトのビルド生成物がストレージを圧迫しているケースが非常に多いです。

  • 手順:
  • IntelliJで該当のプロジェクトを開き、上部メニューの Build > Clean Project(またはMaven/Gradleツールの「clean」タスク)を実行します。
    • これにより、プロジェクトフォルダ内の out や target 、 build フォルダといった一時的なバイナリファイルが削除されます。
    • 二度と触らないプロジェクトであれば、プロジェクトのフォルダごと削除するか、外部ストレージに移動させてください。

現在、IntelliJのプロジェクトでは主にどのビルドツール(Gradle、Mavenなど)を使用していますか? また、PCのOS(Windows または Mac)を教えていただければ、不要な古い設定ファイルを一括で探して削除する具体的なパスをご案内できます。