diff --git a/index.html b/index.html
index 32ca4fc..aa21a09 100644
--- a/index.html
+++ b/index.html
@@ -117,6 +117,7 @@
+
diff --git a/src/core/template.ts b/src/core/template.ts
new file mode 100644
index 0000000..f6a15b9
--- /dev/null
+++ b/src/core/template.ts
@@ -0,0 +1,32 @@
+import * as XLSX from 'xlsx';
+
+/**
+ * Blank input template with the three sheets (교각 · IP · 받침) and the headers the parser looks for.
+ * Value cells are left empty on purpose: pre-filled names would become rows with no 교각명 and raise E05.
+ * Row 1–2 are notes; the parser finds the headers anywhere on the sheet.
+ */
+export function buildTemplateWorkbook(): XLSX.WorkBook {
+ const wb = XLSX.utils.book_new();
+ const note = (text: string) => [text];
+
+ const pier = [
+ note('교각 정보 1행당 교각 1기. 교각명·교각형식(T형/2주형/3주형)·기초 형식(말뚝기초/직접기초/현장타설말뚝)을 적습니다.'),
+ note('방위각은 75°56\'55.9" 형식으로 적습니다. 머리글 행은 바꾸지 마세요.'),
+ ['교각명', '교각형식', '두부보 EL_m', '기초 하단 EL_m', '지반고 EL_m', '기초 형식', '말뚝길이_mm', '두부보 폭_mm', '두부보 길이_mm', '기둥갯수'],
+ ];
+ const ip = [
+ note('IP1–IP4의 좌표(국토좌표계, m)와 코핑 꺾임각(°)을 적습니다. 교각명은 교각 정보와 같아야 합니다.'),
+ note('IP2는 참고용입니다. 프로그램이 IP1–IP3의 1:9 내분점으로 다시 계산해 비교합니다.'),
+ ['교각명', 'IP점', 'x_m', 'y_m', '코핑 꺾임각'],
+ ];
+ const bearing = [
+ note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
+ note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
+ ['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치'],
+ ];
+
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(ip), 'IP');
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(bearing), '받침');
+ return wb;
+}
diff --git a/src/main.ts b/src/main.ts
index 12e6862..bd2624d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,7 @@ import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
+import { buildTemplateWorkbook } from './core/template';
import type { Issue, PierInput, PierType } from './core/types';
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
@@ -520,6 +521,9 @@ $('regen').addEventListener('click', regenerateChecked);
$('project-name').addEventListener('input', updateApply);
$('pick-file').addEventListener('click', () => $('file').click());
+$('download-template').addEventListener('click', () => {
+ XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
+});
$('file').addEventListener('change', async (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
diff --git a/tests/template.test.ts b/tests/template.test.ts
new file mode 100644
index 0000000..c592700
--- /dev/null
+++ b/tests/template.test.ts
@@ -0,0 +1,30 @@
+import * as XLSX from 'xlsx';
+import { describe, expect, it } from 'vitest';
+import { buildTemplateWorkbook } from '../src/core/template';
+import { parseWorkbook } from '../src/core/parseWorkbook';
+
+describe('input template (명세 §2.3 ④)', () => {
+ const wb = buildTemplateWorkbook();
+
+ it('has the three sheets 교각, IP, 받침 in that order', () => {
+ expect(wb.SheetNames).toEqual(['교각', 'IP', '받침']);
+ });
+
+ it('is recognised by the parser: each sheet has its table and no errors', () => {
+ const { piers, issues, sheets } = parseWorkbook(wb);
+ expect(piers).toEqual([]);
+ expect(issues).toEqual([]);
+ expect(sheets.map((s) => [s.name, s.tables])).toEqual([
+ ['교각', { pier: 1, ip: 0, bearing: 0 }],
+ ['IP', { pier: 0, ip: 1, bearing: 0 }],
+ ['받침', { pier: 0, ip: 0, bearing: 1 }],
+ ]);
+ });
+
+ it('round-trips through a file: written and re-read the same way', () => {
+ const bytes = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
+ const back = XLSX.read(new Uint8Array(bytes), { type: 'array' });
+ expect(back.SheetNames).toEqual(['교각', 'IP', '받침']);
+ expect(parseWorkbook(back).issues).toEqual([]);
+ });
+});