エンジニアのはしがき

プログラミングの日々の知見を書き連ねているブログです

Node.jsでお手軽にHTMLをPDF化させてみる

PDFを生成するjsライブラリとして、以前からpdfmake(http://pdfmake.org/#/)を使っていたのですが、 チマチマソースをいじっては実PDFの出力結果を見て、また修正して…といった繰り返しが大変苦痛でした。

HTMLのようにブラウザの開発者ツールでお手軽微調整ができれば、かなり時短になるのになぁ… と思ってネットの海を彷徨っていたら、ドンピシャでそんなライブラリがあったので紹介していきます。

f:id:tansantktk:20201120211026p:plain

html-pdfというjsライブラリを使えば、htmlファイルからpdfを生成することができます。
※Node.js環境でのみ動作します。

github.com

動作環境

  • OS: Microsoft Windows 10 Home(build 19042)
$ nvm version 
1.1.7

$ node -v
v10.22.0

$ npm -v
6.14.6

準備

なにはともあれ、必要なパッケージをnpm installしていきましょう。

$ npm install html-pdf

handlebarsは文字列をhtmlに変換する為に使います。(あらかじめ用意したhtmlファイルのままpdf化させることも可能。その場合は不要なパッケージです。)

$ npm install handlebars

HTMLを文字列で定義する

↓のようなサンプルを用意しました。 f:id:tansantktk:20201120194413p:plain

今回は、このようなHTMLファイルをPDFファイルに変換して出力させてみたいと思います。

html-template.jsというファイルを作り、HTML文字列をjsファイル内で定義します。

html-template.js

exports.header = () => {
    const now = new Date();
    let html = '';
    // {{page}}は現在ページ、{{pages}}は総ページ数を示します。html-pdfが出力時に自動的に数値に置き換えてくれます。
    html += `
        <div style="text-align: right; padding-top: 40px; padding-right: 40px;">
            <div>発行日: ${now.getFullYear()}/${('0' + (now.getMonth() + 1)).slice(-2)}/${('0' + now.getDate()).slice(-2)} ${('0' + now.getHours()).slice(-2)}:${('0' + now.getMinutes()).slice(-2)}:${('0' + now.getSeconds()).slice(-2)}</div>
            <div>{{page}} / {{pages}} ページ</div>
        </div>    
    `;
    return html;
}

exports.main = () => {
    let html = '';
    html += `
        <html>
            <head>
                <meta charset="utf8">
                <style>
                    html, body {
                        margin: 0;
                        padding: 0;
                        font-size: 14px;
                        background: rgb(50,50,50);
                        -webkit-print-color-adjust: exact;
                        box-sizing: border-box;
                    }
                    .page {
                        position: relative;
                        width: 172mm;
                        display: block;
                        background: white;
                        color: black;
                        page-break-after: auto;
                        margin: 50px;
                        overflow: hidden;
                    }
                    @media print {
                        html, body {
                            background: white;
                        }
                        .page {
                            margin: 0;
                            height: 100%;
                            width: 100%;
                        }
                    }
                    .main {
                        margin: 40px 40px 40px 70px;
                    }
                    table, td {
                        border: 1px solid gray;
                    }      
                    td {
                        padding: 5px;
                    }        
                    table {
                        border-collapse: collapse;
                        width: 100%;
                    }
                    .text-center {
                        text-align: center;
                    }
                    .mb-1 {
                        margin-bottom: 10px;
                    }  
                    .sub-title {
                        margin-top: 18px;
                        margin-bottom: 4px;
                        font-size: 1.2em;
                    }
                    .memo {
                        white-space: pre-wrap;
                    }
                </style>
            </head>
            <body>
                <div class="page">
                    <div class="main">
                        <h2 class="text-center">注文書</h2>
                        <div class="mb-1">
                            <div>
                                <div class="company-name">FooBarBaz株式会社 御中</div>
                                <div>
                                    <small>下記の通り、注文致します</small>
                                </div>
                            </div>
                        </div>
            
                        <table class="sender-info">
                            <tbody>
                                <tr>
                                    <td colspan="2">
                                        Raspberry Pi 4B スターターキット ×4<br>
                                        Pi4対応 USB C 電源アダプタ ×4<br>
                                        32GB マイクロSDカード ×10<br>
                                    </td>
                                </tr>                                
                                <tr>
                                    <td>納品希望日時</td>
                                    <td>
                                        <div class="delivery-date">なるはやでお願いします。</div>
                                    </td>
                                </tr>                
                                <tr>
                                    <td>納品場所</td>
                                    <td>
                                        〒123-4567<br>
                                        ***********************************************
                                    </td>
                                </tr>       
                                <tr>
                                    <td>連絡先</td>
                                    <td>TEL: 12-3456-7890</td>
                                </tr>                                                             
                            </tbody>
                        </table>
            
                        <div class="sub-title">◆備考</div>
                        <table class="memo">
                            <tbody>
                                <tr>
                                    <td>備考欄です。</td>
                                </tr>
                            </tbody>            
                        </table>
                    </div>
                </div>
            </body>
        </html>    
    `;
    return html;
}

exports.headerでは、上記のスクリーンショットではお見せ出来ていないのですが、PDFのヘッダ部を定義しています。

exports.mainでは、htmlファイル全体を定義しています。 通常のhtmlと同様にstyleタグ内にCSSを書けば、html-pdfはうまく解釈してくれます。 tableタグ内のcolspanも対応してくれます。 日々、htmlを書く人間にとっては本当に助かる仕様ですね😆

PDF出力処理

それでは、肝心のPDF出力処理を書いていきましょう。 main.jsというファイルを先ほどのhtml-template.jsと同階層に作成します。

// main.js

var pdf = require('html-pdf');
var handlebars = require('handlebars');
const template = require('./html-template');  // html-template.jsをロード

const createPdfAsync = (task, event) => {
    return new Promise(async (resolve, reject) => { 
        const options = { 
            format: 'A4', // 用紙のサイズを指定
            orientation: "portrait",  // 用紙の向きを指定
            // ヘッダ部のテンプレートを指定
            header: {
                height: "28mm",
                contents: template.header(),
            },            
        };        

        // handlebarsで文字列をHTML化
        const htmlString = template.main();
        const html = handlebars.compile(htmlString)({ template: 'HBS' });

        // PDFを保存するパスを指定
        const filePath = './hoge.pdf'
        pdf.create(html, options)
            .toFile(filePath, (err, res) => {
            if (err) reject(err);

            console.log(res)
            resolve(res);
        });         
    });
}
 
const start = async() => {
    await createPdfAsync();
}

start();

optionsでは、PDFの出力設定を定義しています。 headerにHTML文字列を指定すると、PDFの上部に常に指定したHTML文字列で指定した内容がレンダリングされるようになります。

html-template.jsでヘッダ部として定義した↓のHTML文字列は、ここで指定するためのものでした。

        <div style="text-align: right; padding-top: 40px; padding-right: 40px;">
            <div>発行日: ${now.getFullYear()}/${('0' + (now.getMonth() + 1)).slice(-2)}/${('0' + now.getDate()).slice(-2)} ${('0' + now.getHours()).slice(-2)}:${('0' + now.getMinutes()).slice(-2)}:${('0' + now.getSeconds()).slice(-2)}</div>
            <div>{{page}} / {{pages}} ページ</div>
        </div>

その後、handlebars.compile()で文字列をhtmlファイルに変換しています。

あとは、pdf.create(html, options).toFile(filePath, (err, res) => { ... });で生成するだけです。 生成完了後の処理は第2引数にコールバック関数として記述できますので必要に応じてハンドリングしてください。

PDF出力結果

ソースが書けたら実行してみましょう。

$ node ./main.js

うまくいけば、hoge.pdfというPDFが生成されます。

f:id:tansantktk:20201120194849p:plain

なんか違う?

見て分かる通り、html-pdfは完全にHTMLを再現するまでには至っていません。 おおまかな部品の配置は同じですが、テーブルの幅やフォントが違います。

CSSでは、私が触ったところではdisplay: flex;等の比較的新しめのCSSは解釈してくれませんでした。 デザイン面については、ある程度は割り切って使う必要がありそうです。

Bufferも作れるよ

pdf.create(html, options)の後ろのtoFIle()toBuffer()に変えると、Bufferを戻り値で受けることもできます。

...
        pdf.create(html, options)
            .toBuffer((err, buffer) => {
            if (err) reject(err);

            resolve(buffer);
        }); 
...

クライアントからのリクエストに対して、PDFをレスポンスするようなNode.jsサーバを作るなら、toBuffer()で生成したBufferをbuffer.toString('base64')でBase64に変換してレスポンスしたりするかと思います。

まとめ

  • Node.jsが使える環境かつ、テーブルタグのような枠線を多用するデザインのものをPDF化したいならかなり使える
  • 複雑で凝ったデザインのPDFを作るのは他のライブラリ同様に時間がかかりそう

余談

実は元々、AWS Lambda(Node.js)でhtml-pdfを動かそうと思ったのですが、今回書いたソースコードでは、依存ライブラリであるphantomJSが読み込めず動きません。
Lambda Layerで別途phantomJSを読み込んだ上で、パスを設定するといった回りくどいことをしてなんとか動かしました。 その件についてはまた次の機会にでも書こうと思います。