Nuxt.js上でPDFKitを使って安全にフォームの内容をPDF出力する方法

## 今回作ったもの

フォームの内容から領収書発行する機能 

ソース
<https://github.com/sauzar18/nuxt-pdfkit-receipt>

## 環境

- Nuxt.js v2.4.5
- Express.js v4.16.4
- backpack-core
- moment.js
- csurf
- xss
- cookie-parser
- PDFKit

## 各モジュールのインストール

Nuxtのプロジェクト下で

```bash
yarn add backpack-core moment csurf xss pdfkit cookie-parser
```

backpack-coreの設定は下記参照
<https://qiita.com/sauzar18/items/32a8553587e2bdcf3689>

## モジュールの説明

### [moment.js](https://momentjs.com/)

日付と時刻を解析、検証、操作、表示をする

### [csurf](https://github.com/expressjs/csurf)

csrf対策のためにtoken発行をしてくれるExpress用のモジュール

### [xss](https://jsxss.com/en/index.html)

クロスサイトスクリプティング対策用のモジュール

### [cookie-parser](https://github.com/expressjs/cookie-parser)

cookie系の操作をするExpress用のモジュール

### [PDFKit](http://pdfkit.org/)

Nodeおよびブラウザ用のPDFドキュメント生成ライブラリ

Nuxt.jsのプロジェクト内にあるserverフォルダ内の設定をしていきます。

## フォームを安全に送信するためにcsrf対策を設定

>参考記事:Nuxt.jsでcsrfTokenを埋め込みセキュアな通信を行う 
><https://qiita.com/tkow/items/fd0a5ce8ff3d6d17a6c1>

これを参考にして

```js:server/index.js
import express from 'express'
import consola from 'consola'
import { Nuxt, Builder } from 'nuxt'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import csrf from 'csurf'

const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(cookieParser())
app.use(csrf({ cookie: true }))

// Import and Set Nuxt.js options

```

```js:store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)
export const state = () => ({
  csrfToken: null
})
export const mutations = {
  SET_CSRF_TOKEN(state, csrfToken) {
    state.csrfToken = csrfToken
  }
}

export const actions = {
  nuxtServerInit({ commit }, { req }) {
    if (req.cookies) {
      commit('SET_CSRF_TOKEN', req.csrfToken())
    }
  }
}
```

今回はaxios使わないのでaxiosの設定はなし
これでvueファイルでstoreを介してcsrf tokenを取得できるようになります。

次にvueファイルを構築していきます。

## クライアントサイドの構築

```js:pages/index.vue
<template>
  <div class="container">
    <h1 class="title">
      Nuxt.js + pdfkitでフォームの内容をPDF出力
    </h1>
    <form
      class="st-form"
      action="/api/pdf"
      method="POST"
    >
      <input
        :value="$store.state.csrfToken"
        type="hidden"
        name="_csrf"
      >
      <div class="st-datefield">
        <label for="issue">発行日</label>
        <input
          id="issue"
          type="date"
          name="issue"
          required
          aria-required="true"
        >
      </div>
      <div class="st-textfield">
        <label for="client_name">会社名</label>
        <input
          id="client_name"
          type="text"
          name="client_name"
        >
      </div>
      <div class="st-textfield">
        <label for="charge">担当者名</label>
        <input
          id="charge"
          type="text"
          name="charge"
        >
      </div>
      <div class="st-textfield">
        <label for="content">但し書き</label>
        <input
          id="content"
          type="text"
          name="content"
        >
      </div>
      <div class="st-textfield">
        <label for="price">金額</label>
        <input
          id="price"
          type="number"
          name="price"
        >
      </div>
      <div class="st-textfield">
        <label for="bikou">備考</label>
        <textarea
          id="bikou"
          name="bikou"
          cols="30"
          rows="10"
        />
      </div>
      <button type="submit">
        発行
      </button>
    </form>
  </div>
</template>

<style>
.container {
  width: 80%;
  margin: 60px auto;
}

.title {
  font-family: 'Quicksand', 'Source Sans Pro', -apple-system, BlinkMacSystemFont,
    'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  display: block;
  font-weight: 300;
  font-size: 24px;
  color: #35495e;
  letter-spacing: 1px;
  margin-bottom: 20px;
  text-align: center;
}

form {
  width: 480px;
  padding: 20px;
  border: 1px solid #35495e;
  margin: 0 auto;
}
.st-textfield {
  margin-bottom: 20px;
  display: flex;
  flex-direction: column;
}
input,
textarea {
  border: 1px solid #35495e;
}
.st-textfield input {
  height: 36px;
  padding: 0 4px;
}
button {
  cursor: pointer;
  background-color: #35495e;
  color: #fff;
  width: 120px;
  height: 40px;
  border: none;
  border-radius: 2px;
  box-shadow: 0 2px 6px rgba(0,0,0,0.16);
  display: block;
  margin: 0 auto;
}
button:hover,
button:focus {
  background-color: #43607c;
}
button:active {
  box-shadow: none;
}
.st-datefield {
  border-bottom: 1px solid #43607c;
  margin-bottom: 10px;
}
.st-datefield input {
  border: none;
  height: 36px;

}
</style>
```

やっていること

- formのactionでapiにむけてデータ送信
- csrfをstoreから取得

こんな感じでクライアントサイドの構築

## PDFKitにデータを受け取るapiを構築

serverフォルダ下にapiフォルダを作成しapiを管理

```directory
/server
- index.js
- api
    - index.js
    - pdf.js
```

こんな感じでディレクトリをつくります。

### 1. apiを読み込み設定

```js:server/index.js
import express from 'express'
import consola from 'consola'
import { Nuxt, Builder } from 'nuxt'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import csrf from 'csurf'
import api from './api' // 追加

const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(cookieParser())

app.use(csrf({ cookie: true }))
app.use('/api', api) // 追加

// Import and Set Nuxt.js options
const config = require('../nuxt.config.js')
config.dev = !(process.env.NODE_ENV === 'production')

async function start() {
  // Init Nuxt.js
  const nuxt = new Nuxt(config)

  const {
    host = process.env.HOST || '127.0.0.1',
    port = process.env.PORT || 3000
  } = nuxt.options.server

  // Build only in dev mode
  if (config.dev) {
    const builder = new Builder(nuxt)
    await builder.build()
  } else {
    await nuxt.ready()
  }

  // Give nuxt middleware to express
  app.use(nuxt.render)

  // Listen the server
  app.listen(port, host)
  consola.ready({
    message: `Server listening on http://${host}:${port}`,
    badge: true
  })
}
start()
```

### 2. apiを管理する設定

```js:server/api/index.js
import { Router } from 'express'
import pdfContent from './pdf'
const router = Router()

router.use(pdfContent)

export default router
```

pdfkitの処理を書いたファイルを読み込んでいる。

### 3. pdfkitの設定

```js:server/api/pdf.js
import { Router } from 'express'
import PDFDocument from 'pdfkit'
import moment from 'moment'
import xss from 'xss'
const router = Router()

router.post('/pdf', (req, res) => {
  const doc = new PDFDocument({ size: 'A4', margin: 50 })
  const client = xss(req.body.client_name)
  const charge = xss(req.body.charge)
  const content = xss(req.body.content)
  const price = xss(req.body.price)
  const bikou = xss(req.body.bikou).replace(/\r?\n/g, '\n')
  const planTotal = Number(price) * 1.08
  const issue = moment(xss(req.body.issue)).format('YYYY年MM月DD日')
  let filename = '領収書-' + client + '様'
  filename = encodeURIComponent(filename) + '.pdf'
  res.setHeader('Content-disposition', 'attachment; filename="' + filename + '"')
  res.setHeader('Content-type', 'application/pdf')
  doc.font('./static/fonts/meiryo.ttf')
  doc.pipe(res)
  doc.y = 300
  doc.fontSize(10).text(issue, 389, 150, { align: 'right', width: 150 })
  doc.fontSize(18).text('領　収　書', 0, 80, { width: 600, align: 'center' })
  doc.fontSize(10)
  doc.text(charge, 50, 175)
  doc.fontSize(12)
  doc.text('様', 304, 175)
  doc.moveTo(50, 188).lineTo(300, 188).stroke()
  doc.fontSize(10)
  // left block
  doc.fontSize(14).text('領収金額　　　　　' + '￥' + Number(planTotal).toLocaleString() + '-', 0, 220, { width: 600, align: 'center' })
  doc.moveTo(150, 248).lineTo(450, 248).stroke()
  doc.moveDown(1)
  doc.fontSize(10)
  doc.text('但し、', 160, 264).stroke()
  doc.text('として', 402, 264).stroke()
  doc.moveDown(1)
  doc.text(content, 0, 264, { align: 'center', width: 600 }).stroke()
  doc.moveTo(200, 278).lineTo(400, 278).stroke()
  doc.text('上記金額を正に受領いたしました。', 0, 298, { align: 'center', width: 600 })
  // right block
  doc.fontSize(10)
  doc.text('〒000-000', 335, 430, { align: 'left', width: 170 })
  doc.moveDown(0.1)
  doc.text('東京都○○区')
  doc.moveDown(0.1)
  doc.text('TEL:00-0000-0000')
  doc.moveDown(0.5)
  doc.fontSize(12)
  doc.text('株式会社○○')
  doc.moveTo(75, 430).lineTo(150, 430).dash(1, { space: 1 }).stroke()
  doc.moveTo(75, 430).lineTo(75, 505).stroke()
  doc.moveTo(150, 430).lineTo(150, 505).stroke()
  doc.moveTo(75, 505).lineTo(150, 505).stroke()
  doc.fontSize(15)
  doc.text('印紙', 75, 458, { align: 'center', width: 75 })
  doc.fontSize(10)
  doc.text('備考', 50, 564).stroke()
  doc.moveDown(0.1)
  doc.moveTo(50, 580).lineTo(540, 580).dash(0, { space: 0 }).stroke()
  doc.moveTo(50, 580).lineTo(50, 700).stroke()
  doc.moveTo(540, 580).lineTo(540, 700).stroke()
  doc.moveTo(50, 700).lineTo(540, 700).stroke()
  doc.text(bikou, 60, 590).stroke()
  doc.fontSize(15)
  doc.fillColor('black')
  doc.end()
})

export default router
```

ここでやっていること

- bodyParserでフォームの内容を取得
- moment.jsでに日時を整形
- xssでフォームから受け取った値のクロスサイトスクリプティング対策
- PDFKitでPDF出力の形をつくる

これでバックエンドの設定は完了
これでPDFが取得されます。

## PDFKitの使い方

### PDFDocument

PDFDocumentインスタンスを作成する。ここでファイルのサイズなどを設定できる。  
今回はA4サイズに設定

### font

フォントの設置。  
標準のフォントだと日本語が文字化けするので、今回はメイリオを読み込んでいる

### pipe

Nodeストリームに送信するメソッドを呼び出す。
HTTP responseを今回は呼び出している

### y

y軸からどの距離に描写するか宣言

### x

x軸からどの距離に描写するか宣言

### fontSize()

フォントサイズを設定。  
文字を変更するごとに都度設置が必要

### text

文字の設置をするための設定

```js
doc.fontSize(18).text('領　収　書', 0, 80, { width: 600, align: 'center' })
```

フォントサイズ18の領収書という文字列をx軸０y軸80の位置に横幅600で中央揃えで配置

### moveTo

ベクターグラフィックを描写するポイントを設定

### lineTo

線を描写の宣言

### stroke

図であることを指定

### moveTo + lineTo + stoke

```js
doc.moveTo(50, 188).lineTo(300, 188).stroke()
```

y軸50から300まで線をx軸188に描写

### moveDown

文字を改行できる。値は改行のスペース(文字の大きさに対して)

### image

画像を読み込める

```js
doc.image('./static/images/ic_logo.png', 50, 50, { width: 130 })
```

こんな感じで読み込めます。

### addPage()

ページを追加。
この後に設定をするとページが変わる

### end()

PDFの描写を終了

ドキュメントに詳しく載っているので、ちゃんと理解したいならそちらをみながらやってみてください。

## まとめ

PDFKitでPDFを出力できるのはいいけど、形を作るまでが鬼大変。

ある程度どういった形にするかデザインして、例えば、A4ならどの位置に文字、線、画像を設置するかを細かく設計しないとって感じです。

Node環境でないと使えないので注意