LoginSignup
14
9

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-03-05

今回作ったもの

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

ソース
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のプロジェクト下で

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

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

モジュールの説明

moment.js

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

csurf

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

xss

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

cookie-parser

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

PDFKit

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

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

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

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

これを参考にして

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

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ファイルを構築していきます。

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

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を管理

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

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

1. apiを読み込み設定

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を管理する設定

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

router.use(pdfContent)

export default router

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

3. pdfkitの設定

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

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

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

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

moveTo

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

lineTo

線を描写の宣言

stroke

図であることを指定

moveTo + lineTo + stoke

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

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

moveDown

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

image

画像を読み込める

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

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

addPage()

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

end()

PDFの描写を終了

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

まとめ

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

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

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

14
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
9