htmlファイルのみでPDFを生成できるようなのでドラゴン桜計算プリントを作ってみた

# はじめに

　子供が算数のテストで単純な計算ミスをよくしているようなので、
計算力をつけたいと思い、簡単な四則演算のプリントを印刷するために、
Googleスプレッドシートを使って問題を生成して毎日5分で5人家族全員で解いていました。

　これでも結構使えましたが、知り合いの子供もやりたいという声があり、共有を考えましたが、
閲覧権限だと問題のランダム生成ができないし、編集権限をつけると間違って触って式が壊れるかもしれないので、
シンプルにPDFを出力したほうがいいかと思い実現方法を調べてみました。

するとjavascriptのみでPDFを生成してフォントも埋め込めるライブラリがあったので、
それを使って作ってみた手順をまとめました。

# 必要なもの

パソコン：今回は Windows10 の PC を使いましたが、Mac などでも同じようにできます。
ブラウザ(Chrome最新版, IE11 は pdf-lib が対応です。）：動作確認用
テキストエディタ：html 編集用
埋め込み用フォント：[Rounded Mgen+ (ラウンデッド ムゲンプラス)](http://jikasei.me/font/rounded-mgenplus/) から rounded-mgenplus-1m-regular.ttf をダウンロードしました。
Webサーバー：ローカルで html を開くとフォントを読み込めないので、 OS 問わず使いやすい Chrome拡張の [Web Server for Chrome](https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb?hl=ja) を使います。

# 最小限のサンプル

javascript で PDF を生成するライブラリはいくつかあるようですが、
今回は比較的新しそうな [PDF-LIB](https://github.com/Hopding/pdf-lib) を使います。

テキストエディタで以下の html ファイルを作成します。
html を保存した同じ場所(/dev/pdf)に rounded-mplus-1m-regular.ttf も配置します。

```html:/dev/pdf/index.html
<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<script src="https://unpkg.com/pdf-lib"></script>
<script src="https://unpkg.com/@pdf-lib/fontkit/dist/fontkit.umd.js"></script>
</head>

<body>
準備中...

<script>
(async function() {
　//PDF生成
  const pdf = await PDFLib.PDFDocument.create();

  //埋込フォント使用
  pdf.registerFontkit(fontkit);

  //日本語フォント読込
  const font = await pdf.embedFont(await (await fetch('rounded-mgenplus-1m-regular.ttf')).arrayBuffer());

  //ページ追加（単位はポイント、A4サイズにしています）
  const page = pdf.addPage([842, 595]);

  //文字描画（こちらも size, x, y の単位はポイント、原点は左下）
  page.drawText('JavaScript で PDF を生成したよ', { font: font, size: 14, x: 100, y: 300 });

  //PDF表示
  location.href = URL.createObjectURL(new Blob([await pdf.save()], { type: 'application/pdf' }));
})();
</script>

</body>

</html>
```

pdfフォルダは以下の内容になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82535/b7605e18-bac1-8921-bc8d-cb20cf251d51.png)

Webサーバーの設定で、CHOOSE FOLER を押して、/dev/pdf フォルダをしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82535/dca18fd5-8d93-7071-c525-5f08719213b1.png)

表示されている Web Server URL(s) をクリックしすれば以下のように PDF が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82535/69f142cd-e051-6071-de7d-4b40178ea8d2.png)

pdf-lib ではフォントを指定しない場合、日本語が使えないようなので、これが最小のサンプルになると思います。

あとは描画する文字のサイズを計算する font.widthOfTextAtSize, font.heightAtSize, 矩形を描画する page.drawRectangle を使えば任意の位置に文字と枠線を表示できるので、四則計算100問のプリントPDFを作るプログラムを書きました。

# 完成版

```html:完成版
<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="utf-8" />
<script src="https://unpkg.com/pdf-lib"></script>
<script src="https://unpkg.com/@pdf-lib/fontkit/dist/fontkit.umd.js"></script>
</head>

<body>
準備中...

<script>
(async function() {
/*
ランダムで計算100問のPDFを出力する
pdf-libの単位はpt: 1pt = 0.353mm, 1mm = 2.835pt
*/
  const PAGE_WIDTH = 842; //A4 210mm×297mm = 84pt×595pt
  const PAGE_HEIGHT = 595;
  const FONT_SIZE = 10.5;
  const MARGIN = FONT_SIZE * 2; //上下左右余白
  const ROWS = 27; //1ページ行数
  const COLUMNS = 4; //1ページ列数
  const COLUMN_WIDTH = (PAGE_WIDTH - MARGIN * 2) / COLUMNS;
  const THICKNESS = 0.001; //線の太さ
  const PADDING = 0.5; //セルの余白(1で1文字のサイズ)
  const FONT_URL = 'rounded-mgenplus-1m-regular.ttf'; //http://jikasei.me/font/rounded-mgenplus/
  const Y = y => PAGE_HEIGHT - y; //pdf-libのy座標は下から上方向なので上から下方向に変換
  const range = (start/*:Number*/, count/*:Number*/) => Array.from(Array(count)).map((_, i) => i + start);
  const rand_int = max/*:Number*/ => Math.floor(Math.random() * max);
  const isNumber = x => typeof(x) == 'number';
  const expand_text = text => isNumber(text) ? Array.from(Array(Number(text))).reduce((a, i) => a + '　', '') : text;

  //問題、解答作成
  const pairs = range(1, 10).flatMap(x => range(1, 10).map(y => [x, y]));
  const questions = range(1, 100).map(n => {
    const prefix = '[' + n + '] ';
    switch(rand_int(4)) {
    case 0://加算: 0～50 ＋ 0～50
      {
        const left = rand_int(51);
        const right = rand_int(51);
        return [prefix + left + '＋' + right + '＝', left + right];
      }
    case 1://減算: 0～99 －0～99(答えは0以上)
      {
        const pair = [rand_int(100), rand_int(100)];
        const left = pair[0] > pair[1] ? pair[0] : pair[1];
        const right = pair[0] > pair[1] ? pair[1] : pair[0];
        return [prefix + left + '－' + right + '＝', left - right];
      }
    case 2://乗算: 1～10 × 1～10
      {
        const pair = pairs.splice(rand_int(pairs.length), 1)[0];
        const left = pair[0];
        const right = pair[1];
        return [prefix + left + '×' + right + '＝', left * right];
      }
    case 3://除算: 1～100 ÷ 1～10(余りなし)
      {
        const pair = pairs.splice(rand_int(pairs.length), 1)[0];
        const left = pair[0] * pair[1];
        const right = pair[1];
        return [prefix + left + '÷' + right + '＝', left / right];
      }
    }
  });

  //PDF作成
  const pdf = await PDFLib.PDFDocument.create();
  const title = '計算' + questions.length + '問'
  pdf.setTitle(title);
  pdf.setAuthor('nakazawaken1');

  //日本語フォント読込
  pdf.registerFontkit(fontkit);
  const font = await pdf.embedFont(await (await fetch(FONT_URL)).arrayBuffer());
  const unitFontWidth = font.widthOfTextAtSize('響', FONT_SIZE)
  const fontHeight = font.heightAtSize(FONT_SIZE);
  const lineHeight = (PAGE_HEIGHT - MARGIN * 2) / ROWS;

  const drawCell = (page, texts/*:String|Number|[String|Number]*/, x/*:Number|'left'|'right'|'center'*/, y/*:Number*/) => {
    if(!(texts instanceof Array)) texts = [texts];
    const widths = texts.map(t => font.widthOfTextAtSize(expand_text(t), FONT_SIZE) + unitFontWidth * PADDING * 2);
    const totalWidth = widths.reduce((total, width) => total + width);
    if(x == 'left') x = MARGIN;
    else if(x == 'right') x = PAGE_WIDTH - MARGIN - totalWidth;
    else if(x == 'center') x = (PAGE_WIDTH - MARGIN * 2 - totalWidth) / 2;
    texts.forEach((text, i) => {
      text = expand_text(text);
      page.drawText(text, { font: font, size: FONT_SIZE, x: x, y: y - fontHeight / 3 });
      page.drawRectangle({
        x: x - unitFontWidth * PADDING,
        y: y - fontHeight * 1.2 / 3 - fontHeight * PADDING,
        width: font.widthOfTextAtSize(text, FONT_SIZE) + unitFontWidth * PADDING * 2,
        height: fontHeight * (2 / 3 + PADDING * 2),
        borderColor: PDFLib.rgb(0, 0, 0),
        borderWidth: THICKNESS
      });
      x += widths[i];
    });
  };

  //ページ作成
  pages = Array.from(Array(Number(location.search.slice(1) || 1))).map(i => false);
  pages.push(true);
  pages.forEach(answer => {

    const page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);

    let y = MARGIN + (lineHeight - fontHeight) / 2;

    //ヘッダ描画
    drawCell(page, title + (answer ? '解答' : ''), 'left', Y(y));
    drawCell(page, ['氏名', 6, '日時', 6, '試験時間', 6, '点数', 6], 'right', Y(y));
    
    y += lineHeight * 2;

    //計算式描画
    range(0, COLUMNS).forEach(column => {
      const rows = Math.ceil(questions.length / COLUMNS);
      range(0, rows).forEach(row => {
        const question = questions[column * rows + row];
        page.drawText(answer ? question[0] + question[1] : question[0], { font: font, size: FONT_SIZE, x: MARGIN + column * COLUMN_WIDTH, y: Y(y + row * lineHeight + fontHeight / 3) });
      });
    });
  });

  //表示
  location.href = URL.createObjectURL(new Blob([await pdf.save()], { type: 'application/pdf' }));
})();
</script>

</body>

</html>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82535/3ff9f4e8-5906-410d-fad1-06ba39238d75.png)

以下のように作成しました。

* 記入用ページと解答ページを出力します。記入用ページは ?5 のようにパラメータで数を指定するとページが増えるようにしています。これで例えば5人分印刷する際に印刷設定をかえなくてもそのまま印刷できます。
* 各問題はランダムで加算、減算、乗算、除算が出力されます。
* 加算は左辺 0～50、右辺 0～50 のランダムで式が生成されます。
* 減算は左辺 0～99、右辺 0～99 のランダムで答えは0以上になる式が生成されます。
* 乗算は左辺 1～10、右辺 1～10 のランダムで同じ式が出ないよう生成されます。
* 除算は左辺 1～100、右辺 1～10 のランダムで余りなしで同じ式が出ないよう生成されます。

# さいごに

[デモページ](https://qpg.jp/calc100.html)

中1, 小5, 小2と父、母で最初は5分間でやって100点が取れれば1分ずつ短縮していく感じでやっています。