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

【nodejs】puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-jimpで画像分割、Promise編-

【nodejs】puppeteer /  jimp / pdfmakeでレシピPDFを作ってみた-jimpで画像分割、Promise編-の画像

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

前回の「puppeteer / jimp / pdfmakeでレシピPDFを作ってみた-自動スクリーンショット編-」の続きです。

ソースは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. 使用した画像を削除

ここでは4番を説明します。

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

備考

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

処理の考え方

4.リサイズした画像をPDF1ページの高さに合うように分割保存

前回リサイズして保存した画像をそのまま貼り付けて出力するとPDF1枚で画像の下は見切れて出力されます。

pdfmakeで画像を貼り付けるとき、1ページの高さを超える画像については考慮されないためです。1枚の画像がこの高さを超えるときは自分で分割して貼り付けていかなければなりません。

1.保存した画像を取得

まず、フォルダに保存した画像を取得します。
保存したファイルのキャプチャファイルリストを作成し、配列保存をしておきます。

const jimp = require('jimp');
const fs = require('fs');
// 分割した保存先ディレクトリの生成確認
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');
}

ここで気づく人もいるかもしれません。
「あれ?なんでfor文でわざわざ回してるの?フォルダの中身全部取ってそのまま入れておけばいいのでは?」
と。ファイルそのものも連番(1.png、2.png…)になっているのに(※前回ソース参照)。

これはjavascriptのsortが番号順になってくれないので、取得してきたときに順番がおかしくなってしまうからです(文字コードでソートしているようです)。

先の処理では「記事一覧のURLを取得したものを順番に処理していく」ようになっています。主菜、副菜、汁物、デザートの順番でキャプチャを撮って連番保存しています。

もし連番を無視してやってしまったら、1番目に主菜のレシピ、2番目にデザートのレシピ、3番目にまた主菜のレシピ…となりかねません。見返したときに美しくないですね。

これが理由で順番を崩したくなかったので、連番でリストを取得できるように入れ直しています。

2.画像を分割する

いよいよ画像を分割していきます。キャプチャした画像の数だけ繰り返します。

const imageSize = require('image-size');
// 取得したキャプチャをリサイズするときのサイズ
const width = 500;
const height = 750;
// 画像を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)
	    });
	}
}

image-sizeのライブラリを使ってファイルのサイズ情報を取得します。ここでは幅と高さを使用します。

取得した高さ ÷ 1ページのPDFの高さ を切り上げた数が分割に必要なループ回数です。

例えば500の高さに分割したいとき、画像の高さが2400であれば2400÷500=4 あまり400となります。切り上げて5回の分割が必要です。

jimpでファイルを読み込み、crop機能で切り抜いていきます。
cropは切り抜く画像のx位置、y位置、切り抜く幅、高さの順番で指定します。
img.crop(x, y, w, h)としたとき、xは開始点を意味します。切り抜き開始位置はここでは必ず0です。
yは開始の高さ点を意味します。切り抜き開始の高さは1枚目は0ですが2枚目はその続きからとなるので、ループ中に計算し直した値を入れていきます。
wは切り抜く横幅位置を示します。ここでは500で固定しています。
hはxとyの位置から切り抜く高さを示します。ここでは750で固定しています。
よって初回は(x, y) =(0, 0)位置から切り抜き、2回目は(x, y) =(0, 750)から切り抜いていきます。

数学(算数?)でやった、グラフ上の点を打って結ぶと図形になり、その中の面積を求めよーなんてありましたね。あの考え方がわかっていると理解が早いです。

切り抜き位置の参考図

jimpについてはこちらのサイトが参考になりました(公式ももちろん参考になります)。

あれ?うまくいかない。js(非同期)だから…

ここまでやると「あれ?切り抜き画像が2枚目以降うまくできない」という問題にあたります。

切り抜き処理が完了する前に次の切り抜きを指示してしまい、処理がうまく機能していないものと思われます。

基本的にjavascriptは一つの処理が終わったら次に進むのではなく、上から順番にできるものをどんどん進めていく非同期処理です。

今回のような「前の処理の結果を元に次の処理を行いたい」(切り抜き画像の処理を完了してから次の切り抜き位置を指示したい)ときは同期処理を使います。

Promiseです。

Promiseについて

Promiseとは、ざっくりいうとjsの同期処理を助けるものです。
通常、jsは処理を待たずにどんどん次に行ってしまいます。「ちょっとお客さん待ってくださいって」といっても無視してどんどん実行していきます。
これを防いで「お客さんの裾をつかんで待たせる」のがPromiseです(詳細は調べてください)。

動作の流れに

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

と書きました。

1~3まではpuppeteer自身がasync / awaitを使うこともあってそこまで気になりませんでしたが、4番以降は「前の処理が完了していないとうまくいかない」事態に直面します。
例えば3のリサイズ処理中に4の分割処理が先に走ってしまうと、リサイズ前の画像を元に分割するのでループ回数が合わなくなり、エラーが発生します。
また、2のキャプチャ処理中に6の画像削除処理が先に走ってしまうと、リサイズする、分割する、貼り付ける画像がなくなってしまい、3~5の処理が成立しなくなります

そのため、「必ず上から順番に処理をする」ように、Promise制御します。

※基本的にjavascriptは上から下へ順番に、どんどん処理を進めていきます。待ってくれません。

Promiseを使った1~4番までのソース(今回のプログラムの全文)

以上を踏まえてPromiseを導入すると、以下のような書き方になります(※本件の動作部分なので、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');

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

// ▼ 取得する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);
        })();
    });
}).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;
}

puppeteerの処理を完全終了したあと、thenでつなげていきます。
thenの中でもasync / awaitを使用してjimp.read&cropの分割処理が確実に終わってから次のループへいくようにしました。

thenは「前の処理が終わったら」という意味合いの接続の鎖です。チェインと呼ぶようです。

そのthenの中でもPromiseを使います。5番のPDF出力のときに分割が終わっていないとエラーになってしまうからですね。

Promiseについては調べると色々出てきますが、こちらが今回作成プログラムの参考になりました。

これを実行すると、例えば「肉じゃがのレシピ」はこのように2枚に分割されました。

元画像を指定の高さに分割したイメージ

まとめ

キャプチャしたウェブページの画像をPDF1枚の高さに収まるように、画像を分割しました。

nodejsで書いています。puppeteer、画像のリサイズ処理にjimp、画像のサイズを調べるのにimage-sizeを使用しました。
「前の処理を確実に遂行したあとに次の処理を実行する」必要があるため、Promiseを使った書き方を導入しました。これによりpuppeteerの処理の後、画像の分割処理を安全に行うことができるようになりました。

次はpdfmakeを使ってこの分割した画像を1つのPDFにしていきます。