言語(PHP他)nodejs
投稿日 : 2020年10月3日

【nodejs】puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-PDF生成と後処理編-

【nodejs】puppeteer /  jimp / pdfmakeでレシピPDFを作ってみた-PDF生成と後処理編-の画像

こんにちわ、PHPエンジニアのエンジニア婦人(@naho_osada)です。
私はPHPエンジニアとして7年~の経験があります。WordPressは2年半~の経験があります。その他、jQuery、HTML、CSSも使用します。
ここでは主に過去に納品した案件や自サイト運営(エンジニア婦人ノート)で遭遇したことについて書いています。

前回の「puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-jimpで画像分割、Promise編-」の続き、ラストです。

ソースはGitHubで公開しています。

「puppeteer / jimp / pdfmakeでレシピPDFを作ってみた」記事一覧

  1. 【nodejs】puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-自動でスクリーンショット編-
  2. 【nodejs】puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-jimpで画像分割、Promise編-
  3. 【nodejs】puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-PDF生成と後処理編-

プログラムの流れ

やりたいことは「自分のサイトのレシピ情報をpuppeteerで取得してPDFで保存」です。

  1. 一覧ページからキャプチャ対象となるページのURLを全部取得
  2. ページのURLに一つずつアクセスして、該当部分をキャプチャして保存
  3. 保存した画像をPDF1ページの幅に合う大きさにリサイズ
  4. リサイズした画像をPDF1ページの高さに合うように分割保存
  5. PDF出力(画像を1ページずつ貼り付けていく)
  6. 使用した画像を削除

ここでは5番と6番を説明します。

出力されるPDFのサンプルです(20ページほど、少々重たいかもしれません)

備考

ここでは「キャプチャを貼り付けてPDF化」の方法を取ります。
そのため、「改ページの際に画像や文字が切れる部分がある」と思われますが、今回はその部分の調整は一切しません。
要は「ウェブページを見たまま保存する」ということになります。ブラウザ上で「このページを印刷する」とやったときに近い(けど遠い)です。
もし切れる部分が嫌であれば、本文を文字列として取得、画像部分のみをキャプチャし、その配置を調整しながらpdfmakeで出力することになります。
見た目はウェブページの通りにまずなりませんし、画像の挿入部分の調整など、想像しただけで地獄です。自動化にも向かないかもしれません。ただ、やればできるとは思います。

処理の考え方

5.PDF出力(画像を1ページずつ貼り付けていく)

前回分割して保存した画像を、いよいよ1ページずつ貼り付けていきます。

前処理からthenでつなげます。

const jimp = require('jimp');
const pdfMake = require('pdfmake');

// 停止処理(画像切り抜き保存の待ち時間で使用)
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
// ▼ makePdf設定 ▼
// 使用するフォント
const fonts = {
    Roboto: {
        normal: 'fonts/ipag.ttf',
        bold: 'fonts/ipag.ttf',
        italics: 'fonts/ipag.ttf',
        bolditalics: 'fonts/ipag.ttf'
    }
};
const printer = new pdfMake(fonts);
// 余白設定
const marginPdf = [5,10,5,30];
// ▲ makePdf設定 ▲

getPuppeteer.then(() => {
	// 省略
	// 分轄しながら保存していた画像ファイルのパスを次の処理へ渡す
	resolve(capFiles);
}).then((capFiles) => {
    // 分割した画像をPDFファイル形式に貼り付けて出力する
    return new Promise((resolve, reject) => {
        (async() => {
            // 前の画像加工処理が終わっていない場合があるので、念のため一時停止する
            var sleepTime = capFiles.length * 200;
            await sleep(sleepTime);

            // PDF本文に画像を設定していく
            var img = new Array();
            var docContents = new Array();
            for(var i in capFiles) {
                img = {image: capFiles[i], margin: marginPdf};
                docContents.push(img);
            }
            var docDefinition = {
                content: docContents
            };
            var pdfDoc = printer.createPdfKitDocument(docDefinition);
            pdfDoc.pipe(fs.createWriteStream(pdfFile));
            pdfDoc.end();
            resolve(sleepTime);
        })();
    });
})

処理を一旦停止(sleep)させる

冒頭で「await sleep(sleepTime)」しています。本来ならawaitなりPromiseで制御したいところですが、前のjimpによる画像分割処理では完全に終了するまで待ってくれないことがありました。

そのため、エラーにならないように一度ここで処理を強制停止させています。ファイルの数×200ミリ秒としています。10ファイルだったら2秒待ちます。

(この部分を上手くかける方、是非教えて頂きたいです!)

makepdfに渡す画像配列を作る

この処理では前処理で保存していた画像のパスをもらってきています。resolveの中に変数を入れて渡すと次のthenに渡すことができます。
そのパスを元に、pdfmakeで指定通りに貼っていきます。

公式のpdfmakeのサンプルをみるとわかりますが、ここでは

content: [
	{
		image: '画像のパス',
	},
]

の形式で渡します。画像を順番に貼り付けていけばいいので、{image : ‘画像のパス’}が複数並ぶようにしています。

PDF出力

makepdfに決められた書式で出力します。
これで目的の「レシピPDFを出力する」は完了です。

6.作成した画像の削除

ここまでの処理で「レシピPDFを出力する」目的は達成されました。ですが、もう一度処理を実行するともしかしたらおかしなことになるかもしれません…

というのも、ここでは「フォルダに保存されている画像ファイルの数」を計算して処理をしています。もし保存する画像が変わったら分割する回数が変わってしまうかもしれないし、余分な分割画像が残ってしまうかもしれません。
そうなると、次に生成するPDFがおかしなことになってしまうかもしれません。肉じゃがのレシピの最後になぜか豚の角煮のレシピの最後がある、など。

これを防ぐために、最後に「作成した画像の削除」をします。

const fs = require('fs');
// 停止処理(画像切り抜き保存の待ち時間で使用)
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
getPuppeteer.then(() => {
	// 省略
}).then((capFiles) => {
	// 省略
}).then((sleepTime) => {
    // 使用したキャプチャの削除
    return new Promise((resolve, reject) => {
        (async() => {
            await sleep(sleepTime);
            try {
                var datas = fs.readdirSync(capPath);
                for(var i in datas) {
                    fs.unlinkSync(capPath + datas[i]);
                }

                var datas = fs.readdirSync(cropPath);
                for(var i in datas) {
                    fs.unlinkSync(cropPath + datas[i]);
                }

                resolve();
            } catch (error) {
                throw error;
            }
        })();
    });
}).catch((err) => {
    console.err(err);
    exit(0);
});

処理を一旦停止(sleep)させる

冒頭で「await sleep(sleepTime)」しています。本来ならawaitなりPromiseで制御したいところですが、前のPDF生成の前に画像を削除してしまうとエラーになってしまう可能性があります。

これを防ぐために、一度ここで処理を強制停止させています。先ほどと同じ、ファイルの数×200ミリ秒としています。10ファイルだったら2秒待ちます。

キャプチャ画像と分割画像の削除

保存したディレクトリを読み、ファイルを削除していきます。キャプチャ下画像と分割した画像ディレクトリの2か所あるので、それぞれ行います。

余分なものはない方がすっきりしていていいですよね。

本プログラムの全文

本プログラムの全文です(GitHubにあるものと同じです)。

const puppeteer = require('puppeteer');
const jimp = require('jimp');
const fs = require('fs');
const imageSize = require('image-size');
const pdfMake = require('pdfmake');
const { exit } = require('process');

// ▼ makePdf設定 ▼
// 使用するフォント
const fonts = {
    Roboto: {
        normal: 'fonts/ipag.ttf',
        bold: 'fonts/ipag.ttf',
        italics: 'fonts/ipag.ttf',
        bolditalics: 'fonts/ipag.ttf'
    }
};
const printer = new pdfMake(fonts);
// 余白設定
const marginPdf = [5,10,5,30];
// ▲ makePdf設定 ▲

// 取得したキャプチャをリサイズするときのサイズ
const width = 500;
const height = 750;
// 取得したキャプチャとそれを分割した画像の保存先パス
const capPath = './captcha/';
const cropPath = './crop/';
// PDFのファイル名
const pdfFile = './sample.pdf';


// 停止処理(画像切り抜き保存の待ち時間で使用)
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));

// ▼ 取得するURLの設定 ▼
const Url = 'URLを指定';
// 複数URLがある場合(Url以降が異なるものを複数取得する)
var UrlList = new Array();
UrlList.push('Url以下のPathを指定');
// ▲ 取得するURLの設定 ▲

// ▼ 取得するDOM要素の設定 ▼
// 環境によってそれぞれ異なります
const target = '.front-box';
// URLリストのターゲット
const targetDom = 'h3>a';
// キャプチャ取得するときに描画待機する場所(キャプチャする領域より下のタグやクラスを指定)
const capArea = '.bread-list';
// キャプチャするDOM要素やIDなど
const capId = 'article';
// ▲ 取得するDOM要素の設定 ▲

/**
 * PuppeteerでターゲットのURLを取得し、ターゲットURLを訪問して指定部分のキャプチャを撮る
 */
const getPuppeteer = new Promise((resolve, reject) => {
    (async() => {
        try{
            // ▼ puppeteer ▼
            const browser = await puppeteer.launch({
                args: [
                    '--no-sandbox',
                    '--disable-setuid-sandbox'],
            });
            const page = await browser.newPage();
            await page.setViewport({width: 1500, height: 10000});
            // タイムアウトなし
            page.setDefaultTimeout(0);
            // キャプチャの保存先ディレクトリの生成確認
            makeDir(capPath);

            // ▼ キャプチャを取得するURLをまとめる ▼
            var targetUrl = new Array();
            for(var i in UrlList) {
                await page.goto(Url + UrlList[i], {waitUntil: ["load", "networkidle2"]});
                // 指定の部分を取得
                await page.waitFor(target)
                // リンクを取得する
                var datas = await page.$$eval(targetDom, hrefs => hrefs.map((a) => {
                    return a.href;
                }));
                for(var i2 in datas) {
                    targetUrl.push(datas[i2]);
                }
            }
            // ▲ キャプチャを取得するURLをまとめる ▲

            // ▼ 取得したURLを元にキャプチャを取得する ▼
            var cnt = 1;
            var capFile = '';
            for(var i in targetUrl) {
                await page.goto(targetUrl[i], {waitUntil: ["load", "networkidle2"]});

                // 指定の部分を取得
                await page.waitFor(capArea);

                const clip = await page.evaluate(s => {
                    const el = document.querySelector(s)

                    // エレメントの高さと位置を取得
                    const { width, height, top: y, left: x } = el.getBoundingClientRect()
                    return { width, height, x, y }
                }, capId);

                capFile = capPath + cnt + '.png';
                await page.screenshot({clip, path: capFile});

                // 取得したキャプチャをリサイズする
                await jimp.read(capFile).then((data) => {
                    data.resize(width, jimp.AUTO).write(capFile);
                }).catch((err) => {
                    console.log(err)
                });
                cnt++;
            }
            // ▲ 取得したURLを元にキャプチャを取得する ▲
            await browser.close();
            resolve();
            // ▲ puppeteer ▲
        } catch(e) {
            console.error('err' + e);
        }
    })();
});

getPuppeteer.then(() => {
    // puppeteerで取得したキャプチャを分割する
    return new Promise((resolve, reject) => {
        (async() => {
            // 分割した保存先ディレクトリの生成確認
            makeDir(cropPath);
            var len = '';
            var files = new Array();
            var capFiles = new Array();

            // 取得するキャプチャファイルリストの生成
            var datas = fs.readdirSync(capPath);
            len = datas.length;
            for(var i=1; i<=len; i++) {
                files.push(capPath + i + '.png');
            }

            // 画像をPDFに貼るための分割処理
            for(var key in files) {
                var size = imageSize(files[key]);
                var imgW = size.width;
                var imgH = size.height;

                // ループ回数
                var loopCnt = Math.ceil(imgH / height);
                var cropH = 0;
                var cropFile = '';
                for(var i=1; i <= loopCnt; i++) {
                    // 画像の分割
                    await jimp.read(files[key]).then((data) => {
                        var img = data;
                        cropFile = key + '-' + i + '.png';
                        img.crop(0, cropH, imgW, height).write(cropPath + cropFile);
                        cropH = i * height;
                        capFiles.push(cropPath + cropFile);
                    }).catch((err) => {
                        console.log(err)
                    });
                }
            }
            resolve(capFiles);
        })();
    });
}).then((capFiles) => {
    // 分割した画像をPDFファイル形式に貼り付けて出力する
    return new Promise((resolve, reject) => {
        (async() => {
            // 前の画像加工処理が終わっていない場合があるので、念のため一時停止する
            var sleepTime = capFiles.length * 200;
            await sleep(sleepTime);

            // PDF本文に画像を設定していく
            var img = new Array();
            var docContents = new Array();
            for(var i in capFiles) {
                img = {image: capFiles[i], margin: marginPdf};
                docContents.push(img);
            }
            var docDefinition = {
                content: docContents
            };
            var pdfDoc = printer.createPdfKitDocument(docDefinition);
            pdfDoc.pipe(fs.createWriteStream(pdfFile));
            pdfDoc.end();
            resolve(sleepTime);
        })();
    });
}).then((sleepTime) => {
    // 使用したキャプチャの削除
    return new Promise((resolve, reject) => {
        (async() => {
            await sleep(sleepTime);
            try {
                var datas = fs.readdirSync(capPath);
                for(var i in datas) {
                    fs.unlinkSync(capPath + datas[i]);
                }

                var datas = fs.readdirSync(cropPath);
                for(var i in datas) {
                    fs.unlinkSync(cropPath + datas[i]);
                }

                resolve();
            } catch (error) {
                throw error;
            }
        })();
    });
}).catch((err) => {
    console.err(err);
    exit(0);
});

/**
 * makeDir
 * 指定パスのディレクトリを生成する
 * 既にディレクトリがある場合は何もしない
 * 再帰的処理は行っていない(1階層のみ)
 * @param path 生成パス
 * @return true
 */
function makeDir(path) {
    if(!fs.existsSync(path)) {
        fs.mkdirSync(path);
    }
    return true;
}

まとめ

分割した画像をmakepdfを使って、1ページに入る状態で貼り付けて、途中で切れることなく出力することができました。

ブラウザで「このページを印刷する」をしたときに近い状態ですが、表示するエリアを限定したことで「Webページの装飾を見たままで」「エリアを限定して」PDFにすることができました。

他のサイトでも、もし「見たまま保存したい」と思ったら使える方法かなと思います。