はじめに
子供が算数のテストで単純な計算ミスをよくしているようなので、
計算力をつけたいと思い、簡単な四則演算のプリントを印刷するために、
Googleスプレッドシートを使って問題を生成して毎日5分で5人家族全員で解いていました。
これでも結構使えましたが、知り合いの子供もやりたいという声があり、共有を考えましたが、
閲覧権限だと問題のランダム生成ができないし、編集権限をつけると間違って触って式が壊れるかもしれないので、
シンプルにPDFを出力したほうがいいかと思い実現方法を調べてみました。
するとjavascriptのみでPDFを生成してフォントも埋め込めるライブラリがあったので、
それを使って作ってみた手順をまとめました。
必要なもの
パソコン:今回は Windows10 の PC を使いましたが、Mac などでも同じようにできます。
ブラウザ(Chrome最新版, IE11 は pdf-lib が対応です。):動作確認用
テキストエディタ:html 編集用
埋め込み用フォント:Rounded Mgen+ (ラウンデッド ムゲンプラス) から rounded-mgenplus-1m-regular.ttf をダウンロードしました。
Webサーバー:ローカルで html を開くとフォントを読み込めないので、 OS 問わず使いやすい Chrome拡張の Web Server for Chrome を使います。
最小限のサンプル
javascript で PDF を生成するライブラリはいくつかあるようですが、
今回は比較的新しそうな PDF-LIB を使います。
テキストエディタで以下の html ファイルを作成します。
html を保存した同じ場所(/dev/pdf)に rounded-mplus-1m-regular.ttf も配置します。
<!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フォルダは以下の内容になります。
Webサーバーの設定で、CHOOSE FOLER を押して、/dev/pdf フォルダをしています。
表示されている Web Server URL(s) をクリックしすれば以下のように PDF が表示されます。
pdf-lib ではフォントを指定しない場合、日本語が使えないようなので、これが最小のサンプルになると思います。
あとは描画する文字のサイズを計算する font.widthOfTextAtSize, font.heightAtSize, 矩形を描画する page.drawRectangle を使えば任意の位置に文字と枠線を表示できるので、四則計算100問のプリントPDFを作るプログラムを書きました。
完成版
<!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>
以下のように作成しました。
- 記入用ページと解答ページを出力します。記入用ページは ?5 のようにパラメータで数を指定するとページが増えるようにしています。これで例えば5人分印刷する際に印刷設定をかえなくてもそのまま印刷できます。
- 各問題はランダムで加算、減算、乗算、除算が出力されます。
- 加算は左辺 0~50、右辺 0~50 のランダムで式が生成されます。
- 減算は左辺 0~99、右辺 0~99 のランダムで答えは0以上になる式が生成されます。
- 乗算は左辺 1~10、右辺 1~10 のランダムで同じ式が出ないよう生成されます。
- 除算は左辺 1~100、右辺 1~10 のランダムで余りなしで同じ式が出ないよう生成されます。
さいごに
中1, 小5, 小2と父、母で最初は5分間でやって100点が取れれば1分ずつ短縮していく感じでやっています。