0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

問題用紙PDFから国語の縦書き解答用紙を自動生成するWebツールを作った

0
Last updated at Posted at 2026-07-07

中学校で国語を教えている妻は、定期テストのたびに解答用紙を Excel で作っていました。

方眼紙状にしたセルを結合して、縦書きの解答欄を作り、字数指定のマス目を調整し、最後に印刷してズレを確認する。前回のファイルを流用しても、設問数や字数が違えば結局レイアウトを組み直すことになります。

この作業に毎回1時間弱かかっていたので、問題用紙PDFから解答用紙のたたき台を作るWebツールを作りました。

noteには利用者向けの紹介記事を書いたので、この記事では実装寄りの話を書きます。

作ったもの

「解答用紙つくるくん」という、中学国語向けの縦書き解答用紙作成ツールです。

できることは大きく4つです。

  • 問題用紙PDFをアップロードすると、AIが大問・小問・解答欄タイプ・字数指定を読み取り、解答用紙の初期案を作る
  • 初期案を画面上で編集できる
  • A3/B4/A4、マス目サイズ、記号欄、記述欄、抜き出し欄、作文欄などを調整できる
  • ブラウザ印刷で、10mm四方などの実寸マス目として出力できる

image.png

image.png

妻の実運用では、従来1時間弱かかっていた作業が5分くらいになりました。AIの読み取り結果をそのまま使うというより、「8割ほどできた初期案を直す」体験を狙っています。

設計方針

最初に決めたのは、AIにレイアウトまで任せないことです。

解答用紙生成には、かなり性質の違う2つの処理があります。

  1. 問題用紙PDFから「論理構造」を読む
  2. 論理構造を紙面に収める「物理レイアウト」を決める

この2つを分離しました。

PDF
  ↓
Claude: 問題構造をJSONにする
  ↓
EditorDoc: エディタ用の内部表現に変換する
  ↓
layoutSheet(): 紙面上の罫線・文字・当たり判定を決定的に生成する
  ↓
React: プレビューと印刷用DOMを描画する

AIの責務は「問題文に書かれている情報を拾う」だけです。マス目の寸法、段組、カラムまたぎ、観点欄との整列などは、TypeScriptの純関数で決めています。

この分離にした理由は、AIに帳票レイアウトを直接作らせると、再現性と微調整が難しくなるためです。解答用紙は最終的に紙で配るものなので、「同じ入力なら同じ紙面になる」「人間が直せる」「印刷寸法が崩れない」ことを優先しました。

AI抽出: PDFから論理構造だけを取り出す

AI抽出の出力は、だいたい次のような構造です。

interface ExtractionResult {
  meta: {
    title: string | null;
    grade: string | null;
    overallConfidence: number;
  };
  sections: Section[];
}

interface Section {
  label: string;
  passageType:
    | "評論・説明文"
    | "小説・随筆"
    | "詩歌"
    | "古文"
    | "漢文"
    | "言語知識"
    | null;
  questions: Question[];
}

interface Question {
  label: string;
  children: Question[];
  answerField: AnswerField | null;
  confidence: number;
}

ポイントは、設問を再帰構造にしていることです。

国語のテストでは、大問の下に「問一」、その下に「①」「②」、さらに「A」「B」のような階層が出ることがあります。最初から木構造として扱うことで、画面上のツリー編集と紙面のラベル表示を同じデータから作れます。

解答欄は、意味寄りの型で抽出します。

interface AnswerField {
  type:
    | "choice"
    | "extraction"
    | "description"
    | "composition"
    | "kanji_reading"
    | "kanji_writing"
    | "term"
    | "fill_in"
    | "ordering";
  kanten: "knowledge" | "thinking" | null;
  charLimit: {
    min: number | null;
    max: number | null;
    approximate: boolean;
    includesPunctuation: boolean | null;
  } | null;
  extractionFormat: {
    style: "full" | "endpoints";
    endpointCharCount: {
      start: number | null;
      end: number | null;
    } | null;
  } | null;
  fixedParts: {
    prefix: string | null;
    suffix: string | null;
  } | null;
  answerCount: number;
  points: number | null;
  note: string | null;
  confidence: number;
}

ここでも、AIには「何マスでどこに置くか」は決めさせません。

たとえば「二十字以内で説明しなさい」は description + charLimit.max = 20 として抽出し、その後のエディタ変換で「20字のマス目欄」にします。「最初と最後の三字を抜き出しなさい」は extractionFormat.style = "endpoints" として抽出し、表示側で「始」「終」の欄にします。

2フェーズ抽出にした理由

PDF全体を一度にJSON化しようとすると、設問の抜けやJSON崩れが起きやすくなります。そこで、抽出は2フェーズにしています。

  1. フェーズ1: PDF全体から大問の一覧だけを読む
  2. フェーズ2: 大問ごとに詳細な設問構造を読む

実装では、ClaudeのPDF入力に同じPDFを渡しつつ、フェーズ2では「出現順N番目の大問だけ」を対象にします。大問ラベルが重複していても処理できるよう、ラベル単体ではなく出現順で指定しています。

また、PDFと共通システムプロンプトをキャッシュ境界の前に置き、コールごとに変わる指示だけを末尾に置くようにしました。大問ごとに複数回呼ぶ構成なので、prompt caching が効く形にしています。

リクエスト側では最低限の制限も入れています。

  • PDFはbase64で受け取る
  • PDFサイズは3MBまで
  • JSONとしてパースできない場合は1回だけリトライする
  • APIキーはVercelのサーバーレス関数側に置き、クライアントには出さない

エディタ: AI結果は初期値でしかない

AI抽出は便利ですが、完璧ではありません。

小問を見落とすこともあれば、記述問題と抜き出し問題を取り違えることもあります。そのため、このツールではAI抽出結果をそのまま完成品にするのではなく、エディタ用の内部表現に変換して、人間が必ず直せるようにしています。

エディタ内部では、抽出スキーマとは別に EditorDoc を正本にしています。

export interface EditorDoc {
  version: 1;
  title: string;
  gradeLabel?: string;
  sections: SectionNode[];
}

export interface SectionNode {
  id: string;
  label: string;
  questions: QNode[];
}

export interface QNode {
  id: string;
  label: string;
  children: QNode[];
  block?: Block;
}

抽出スキーマは「AIが読む意味」に寄せています。一方、エディタ内部表現は「人間が編集し、レイアウトに流す形」に寄せています。

たとえば、AIは kanji_readingkanji_writing を区別しますが、紙面上はどちらも自由記入枠として扱えます。逆に、端点抜き出しや作文欄は紙面上の見た目が特殊なので、エディタ側では専用のブロックタイプにしています。

export type BlockType =
  | "choice"
  | "cells"
  | "box"
  | "endpoints"
  | "composition"
  | "break";

この変換を一方向にしたのも意図的です。AIの抽出結果を保存形式にするのではなく、ユーザーが直した後の EditorDoc を保存形式にします。

レイアウト: 紙面配置は純関数で決める

レイアウトエンジンは、ReactやDOMに依存しない純関数にしています。

function layoutSheet(
  doc: EditorDoc,
  config: LayoutConfig,
  scale: Scale,
): SheetLayout

返すのは、描画済みHTMLではありません。罫線、文字、網掛け、クリック領域などの「置くべきもの」のリストです。

interface SheetLayout {
  segments: Seg[];
  texts: TextItem[];
  shades: Rect[];
  regions: HitRegion[];
  meta: {
    H: number;
    totalColumns: number;
    sheetW: number;
    sheetH: number;
    overflow: {
      exceeded: boolean;
      byUnits: number;
    };
  };
}

これにより、レイアウトの単体テストが書きやすくなりました。ブラウザを立ち上げなくても、「同じ入力から同じ線分リストが出るか」「ブロックが列高を超えていないか」「線分が重複していないか」を検証できます。

ブロックを最小単位にする

紙面配置の基本単位は、設問ラベルと解答欄をセットにした「ブロック」です。

問一 + 解答欄
問二 + 解答欄
問三 + 解答欄

通常のブロックは分割しません。問ラベルだけが列末に残って、解答欄が次の列に行くようなレイアウトは避けます。

ただし、作文欄や長いマス目欄のように1列に収まらないものは、カラムまたぎとして分割します。その場合も、各列頭に必要なラベルを再掲し、解答欄として読める形にします。

この配置は packSection で決めています。大まかには、ツリーをリーフに展開し、列に入るものは積み、入らないものは次列へ送り、1列にも入らないものはspanとして分割します。

export function packSection(questions: QNode[], H: number): Unit[] {
  const leaves = flattenLeaves(questions);
  const units: Unit[] = [];
  let cur: Piece[] = [];
  let used = 0;

  // ...
}

地味ですが、ここが国語の解答用紙らしさにかなり効きます。

実寸固定: 紙を大きくしてもマス目は大きくしない

もう1つ大事だったのは、マス目を実寸固定にすることです。

たとえば10mm四方のマス目を指定したら、A4でもB4でもA3でも10mmです。紙を大きくしたときに変わるのは、マス目の大きさではなく、入る列数や行数です。

export interface LayoutConfig {
  paperSize: "A3" | "B4" | "A4";
  cellSizeMm: number;
  showRubricBoxes: boolean;
  rubricCategories: string[];
}

これは教育現場の印刷物として重要でした。画面上でそれっぽく見えることより、印刷したときに「生徒が書けるサイズの欄」になっていることを優先しています。

罫線はCSS border任せにしない

実装していて一番泥臭かったのは罫線です。

最初はCSSのborderで枠を作ればよいと思っていました。しかし、縦書きのマス目、カラムまたぎ、観点欄、印刷を組み合わせると、次のような問題が出ました。

  • 方向によって外枠が欠ける
  • 隣接セルのborderが重なって線が太る
  • サブピクセル丸めで細い線が消える
  • 画面プレビューと印刷結果の見え方がずれる

最終的には、レイアウトエンジンが罫線1本ごとの線分リストを出し、描画層はそれを置くだけにしました。

export interface Seg {
  kind: "h" | "v";
  x: number;
  y: number;
  len: number;
  style: "solid" | "dashed";
}

さらに、emit段階で多少重複や端点の誤差が出ても、最後に normalizeSegments で同一線分のマージと端点スナップを行います。

「CSSで枠を書く」のではなく、「帳票の線分を計算して描く」に寄せたことで、印刷物としての安定性が上がりました。

テスト

テストは3層に分けています。

  1. エンジン単体テスト
  2. Playwrightによるインクチェック
  3. 目視比較

エンジン単体では、レイアウト出力のスナップショットや不変条件を見ます。

Playwrightでは、スクリーンショット全体のピクセル一致ではなく、エンジンが出した各線分の位置に実際にインクがあるかをサンプリングします。全面ピクセル比較はフォントや環境差で壊れやすいので、罫線欠けの検出に寄せました。

目視比較では、妻がExcelで作っていた過去の解答用紙と並べて、列の並びやラベルの出方が自然かを確認しています。

技術スタック

現在の構成はシンプルです。

  • React
  • TypeScript
  • Vite
  • Vitest
  • Playwright
  • Vercel
  • Claude API

Next.jsではなくViteにしたのは、アプリ本体がほぼクライアント完結で、必要なサーバー処理がAI抽出APIだけだったためです。Vercelのサーバーレス関数を /api/extract として置けば足りました。

作ってみて分かったこと

今回のようなツールでは、AIに全部やらせるより、AIの担当領域を狭くした方が作りやすかったです。

AIに向いていたこと:

  • PDFから設問構造を読む
  • 字数指定や抜き出し条件を拾う
  • 自信がない箇所にconfidenceを付ける

AIに任せなかったこと:

  • 紙面の段組
  • マス目の実寸
  • カラムまたぎ
  • 罫線の所有関係
  • 印刷用レイアウト

帳票系のアプリでは、AI出力をそのまま画面に出すより、「AIで初期値を作る」「決定的ロジックで整える」「人間が最後に直せる」という分担がかなり相性よさそうです。

今後

もともと妻専用に作ったものなので、まだ足りないところはあります。

  • 問題用紙によってAI抽出が外れるケースの収集
  • 収まらないときのUI改善
  • 国語以外の教科で使えるかの検証
  • よく使う解答欄パターンの追加

現在は無料で公開しています。試していただける方がいれば、フィードバックをもらえると助かります。

フィードバックフォーム:

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?