テクノロジー

文字列やHTMLを投げるとPDFを返してくるAPIを手軽に作る

動機

PDFから印刷した紙面に手書きで文字を記載して送付する業務を経験したことがある皆さん、書いていて面倒だなと思ったことはありませんか?

私は思いました。
なので、もっと様々な人にPDFファイルは簡単に作れるということを知ってほしい、そんな気持ちでこの記事を書いています。

用途としては、入力事項からPDFを作成するようなアプリケーションを作成したり、毎月作成する必要があるような書類を自動生成したり、名前などの一部の入力事項のみが異なる類似書類を大量生成するようなケースを想定しています。

一枚だけ作成したいなどの場合は、なんだかんだ言って適当にHTML書いてPDFに印刷するのが早くて自由度が高いと思います。
emmetなどを用いてささっと書けば、それほど時間もかからず書けるはずです。
特に書式にこだわらないテキストだけの書類の場合は、markdownで文章を作成してpdfへの変換ツールで変換してあげると楽でしょう。

みんな大好きJavaScriptでPDFを作る手段は複数存在する

あまり話題にならない印象ですが、PDFを作成するライブラリの歴史は実は意外と長く、それなりに安定したライブラリが複数存在します。

例えば、はるか昔、新人の頃、私が業務で作成した社内向けツールでは、pdfmakeというライブラリを使用しました。
ゴリゴリとjsonを書くとPDFに変換してくれるという、手間だけど便利なライブラリです。
便利ですが、jsonから作成するという関係上独自の書式に縛られる点や、日本語フォント設定がとても面倒という点が不自由でした。

また、有名どころだとPDFKitjsPDFなどもあります。

ですが、今回はこれら以外でフロント技術者なら一度は触ったことがある or 名前を聞いたことがあるツールを使用してPDFを作成しようかと思います。

理由は単純で、今までに挙げた3つはそこそこのinputを必要とするからです。特に設定や日本語フォント周り。
個人的な意見ですが、ある程度整った書式のものを作る場合は、慣れるまではHTML書いてPDFに変換した方が早いです。

必要なら新しいものを覚えることは大切ですが、手元の技術で苦労せずにできるなら、面倒なことはやっていられません。

そんなわけで、今回使用するのはpuppeteerです。

E2Eテストなどでツールを検討したことがある人は、CypressやPlaywrightなどと合わせてテストツールの候補に検討したことがあるでしょう。
また、Node.jsでスクレイピングを試みた方であれば、多くの方がこれを利用したかと思います。

puppeteerとはなんぞや

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

https://github.com/puppeteer/puppeteer

ざっくり言うと、ブラウザ操作の自動化ツール、Beautiful Soupなんかを触ったことがある人なら何となくイメージが付くかもしれません。

公式のサンプルコードを少し書き換えたものを用意したので見てみましょう。

// fetchMynaviPDF.js
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://www.mynavi.jp/');
  await page.pdf({ path: 'mynavi.pdf', format: "A4" });

  await browser.close();
})();

これは、弊社のホームページをA4のPDFにして保存するコードです。
関数が比較的わかりやすい名前をしているので、Javascriptを普段あまり読まない人も、ホームページに移動してPDFを作成しているということが何となくわかるかと思います。

さて、このコードを見て気が付くと思います。
これ、ページ遷移する代わりにHTMLを渡せば、HTMLからPDFを作成できますね。
つまり、渡すHTMLに任意の値を挿入したり、あるいはHTML文字列を直接渡せるような環境があれば、お手軽にPDFを作成できることになります。

puppeteerを使ってPDFを作成する

今回は、テンプレートとなるHTMLに任意の文字列を挿入することでPDFを作成する、そんなアプリケーションを作成します。

アプリケーション全体の流れを確認します。

  1. クライアント側から何らかの手段でバックエンドのAPIを叩く
  2. バックエンド側でAPIを叩いた際に付与された値を、テンプレートのHTMLに挿入する。
  3. バックエンド側でpuppeteerを用いてPDFを作成、クライアント側に返送する。

生成するPDFは、適当なイベントへの参加申請書類とします。

バックエンド側でpuppeteerを用いてPDFを作成、クライアント側に返送する

まず、リクエスト受けたらテンプレートとなるファイルのPATHからテンプレートを読み込み、PDFのBufferを返送用するAPIサーバーを作成します。
サーバーについてはこちらにコードを貼る都合上expressを用いましたが、真面目に作るならNest.jsなどの方が良いです。

// server.js
const express = require('express')
const fs = require("fs")
const path = require("path")
const puppeteer = require("puppeteer")

const app = express()
const port = 8080

app.get('/', (req, res) => {
  res.send('please request to /api/generate/sample.pdf')
})

app.get('/api/generate/sample.pdf', async (req, res) => {
  const generatePDF = async () => {
    const htmlTemplate = fs.readFileSync(
      path.resolve(__dirname, './template/template.html'),
      'utf-8'
    )
    const browser = await puppeteer.launch({ headless: true })
    const page = await browser.newPage()
    await page.setContent(htmlTemplate)
    const buffer = await page.pdf({
        printBackground: true,
        format: 'A4',
        margin: {
          top: 0,
          right: 0,
          bottom: 0,
          left: 0
        }
    })
    browser.close();
    return buffer
  }

  const content = await generatePDF()
  res.send(content)
})

app.listen(port, () => {
  console.log(`listening ${port} port`)
})

コードの約半分Express関連の処理なので、本来必要な処理のみ抽出します。

// js
  const generatePDF = async () => {
    const htmlTemplate = fs.readFileSync(
      path.resolve(__dirname, './template/template.html'),
      'utf-8'
    )
    const browser = await puppeteer.launch({ headless: true })
    const page = await browser.newPage()
    await page.setContent(htmlTemplate)
    const buffer = await page.pdf({
        printBackground: true,
        format: 'A4',
        margin: {
          top: 0,
          right: 0,
          bottom: 0,
          left: 0
        }
    })
    await browser.close();
    return buffer
  }

上記の2~4行目で./template/template.htmlにあるHTMLファイルを取得、それを8行目でpuppeteerに渡したのち、PDFに変換しています。

APIを叩いた際に付与された値を、テンプレートのHTMLに挿入する

GETあるいはPOSTで飛んできたデータを、HTMLテンプレートの特定の箇所と置換します。
今回はイベント参加申請書類ということなので、日付と名前を置換することにします。

先にテンプレートに置換対象の文字列を仕込んでおきます。
今回は日付と名前ということなので、それぞれ{{date}}, {{name}}としておきます。

置換については、JavaScriptのreplace関数で行います。

// js
  const generatePDF = async () => {
    const htmlTemplate = fs.readFileSync(
      path.resolve(__dirname, "./template/template.html"),
      "utf-8"
    );

    // クエリパラメータから取得
    const name = req.query?.name;
    const date = req.query?.date;
    // 置換
    const htmlText = htmlTemplate.replace(/{{name}}/g, name).replace(/{{date}}/g, date)

    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setContent(htmlText);
    const buffer = await page.pdf({
      printBackground: true,
      format: "A4",
      margin: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
      },
    });
    browser.close();
    return buffer;
  };

これで、HTMLから任意の文字列に置換したPDFを生成できるようになりました。

クライアント側から何らかの手段でバックエンドのAPIを叩く

ブラウザの場合、該当のURLにクエリパラメータを足して遷移すると、ダウンロードが開始されます。
ただし、fetchなどでAPIを叩いた場合、取得するのはPDFのbufferなので、そこの変換処理を行ってあげる必要があります。
大量にファイルを作成したいとき、手元にcsvなどがあるならzxでfs使って1行ずつ読みながらwget叩くと楽かもしれません。

ローカル上で実行している場合は、const buffer = を外してサンプルコードを参考にpage.pdfのオプションに適当なpathを追加すれば、Experessが稼働している場所にファイルが生成されるのでそちらの方が手軽でしょう。

おまけ

zxを使用するとこのようにコードを減らせるので便利です。

// server.js
#!/usr/bin/env zx

import express from "express";
import puppeteer from "puppeteer";

const app = express();
const port = 8080;

app.use(express.json())

app.get("/", (req, res) => {
  res.send("please request to /api/generate/sample.pdf");
});

app.get("/api/generate/sample.pdf", async (req, res) => {
  const generatePDF = async () => {

    // POSTから取得
    const name = req.query?.name;
    const date = req.query?.date;
    // 取得 & 置換
    const htmlText = (
      await $`sed -e "s/{{name}}/${name}/g" -e "s/{{date}}/${date}/g" ./template/template.html`
    ).stdout

    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setContent(htmlText);
    const buffer = await page.pdf({
      printBackground: true,
      format: "A4",
      margin: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
      },
    });
    browser.close();
    return buffer;
  };

  const content = await generatePDF();
  res.send(content);
});

app.listen(port, () => {
  console.log(`listening ${port} port`);
});

sedコマンドを使えば、ファイル取得と置換を一つの処理で済ませられるので本当に便利です。
zxについてはこちらの記事で紹介しているので、ぜひ触ってみてください。

【zx】手軽にJavaScriptにLinuxコマンドを埋め込み実行する

※本記事は2022年07月時点の内容です。

テクノロジーの記事一覧
タグ一覧
TOPへ戻る