试卷模板
试卷模板(testpaper)是 endfield-docmaker 中用于生成《明日方舟:终末地》世界观风格考试试卷 PDF 的文档模板,它基于 Typst 排版系统的 @this/ezexam:0.3.2 包,把表单字段(出题机构、年份、科目、密级、考试时长、答题纸内容等)编译为一份完整的试卷源码。
目的与范围
本页覆盖试卷模板的完整实现:字段定义与表单结构、默认值(内置一份完整的数学试卷示例内容)、generateTypstSource 的 Typst 源码生成逻辑、文件命名规则、i18n 文案,以及它与模板系统其余部分的关系。
本页不覆盖以下内容,它们属于兄弟页面:
- 模板注册表与通用表单渲染机制 —— 见「模板系统」相关页面(
src/lib/templates/types.ts中的通用契约在此仅作引用说明) - 公文模板(
official-doc)等其他模板的具体实现 - Typst 编译 / 预览 / 虚拟文件系统(VFS)的运行时管线
- 出题机构常量(
ISSUERS、getLogoScales、issuerExt)的完整定义,见相关常量页面
概述
试卷模板的核心是一个符合 TemplateDefinition 契约的对象 testpaperTemplate,定义于 src/lib/templates/testpaper.ts。它声明了:
- 元信息:
id: 'testpaper'(同时作为存储键后缀)、本地化名称m.template_testpaper()(中文为「试卷」)、storageVersion: 3(存储结构版本号,结构变化时递增以触发迁移) - 表单字段:14 个字段,覆盖选择器(出题机构、纸张大小)、数字输入(年份)、文本输入(标题、科目、密级、试卷类型、考试时长)、KV 网格(附加考试信息)、开关(显示答案、段落两端对齐、显示评分框)、文件列表(内置图片素材)、大文本域(试卷内容)
- 默认值:内置一套完整的「全文明环带普通高等学校招生统一考试·数学」示例试卷,包含单选题、多选题、填空题、解答题四类题型,用户开箱即可预览效果
- 代码生成:
generateTypstSource将表单值拼接为导入ezexam包的 Typst 源码 - 文件命名:
getFileName生成"{year}年{title} {subject}.pdf"形式的下载文件名
该模板在文档库 UI 中通过 testpaper: ExamIcon 映射显示为考试图标(见 DocLibrary.svelte)。
架构
架构要点:
- 契约驱动:
testpaperTemplate必须满足TemplateDefinition(见 types.ts),因此它可以被通用表单渲染器直接消费,而无需任何模板专用 UI 代码。这是"数据即模板"的设计:新增模板只需声明字段与生成函数,不改动渲染层。 - 领域类型收窄:
TestpaperValues接口将松散的Record<string, unknown>收窄为强类型结构,generateTypstSource内部通过as unknown as TestpaperValues做一次断言后即可安全访问字段(见 testpaper.ts)。 - 素材内嵌:两张示例图片通过 Vite 的
?url导入被预载到 VFS,使默认试卷内容中image("6.png")、image("17.png")这类引用能直接解析。
数据模型:TestpaperValues
模板的表单值结构由本地接口 TestpaperValues 描述:
1export interface TestpaperValues {
2 issuer: IssuerKey;
3 year: string;
4 title: string;
5 subject: string;
6 paperSize: string;
7 examType: string;
8 examDuration: string;
9 examInfo: KvEntry[];
10 showAnswer: boolean;
11 parJustify: boolean;
12 secret: string;
13 showScoreBox: boolean;
14 docContent: string;
15}Source: testpaper.ts
设计意图:year 用 string 而非 number,因为表单输入天然是文本,非法输入由 parseYear 在生成阶段统一兜底(详见"边界与容错"一节);examInfo 直接复用 KvGrid.svelte 导出的 KvEntry 类型,保证表单组件与模板层不重复建模。
字段清单
14 个字段按 gridCols: 3 的三列网格排布,colspan 控制跨列:
| 字段 key | 类型 | colspan | 选项/约束 | UI 文案(zh) | 默认值 |
|---|---|---|---|---|---|
issuer | select | 1 | ISSUERS 全部机构 | 颁发机构 | ISSUERS[0].key |
paperSize | select | 1 | a3 / a4 | 纸张大小 | 'a3' |
year | number | 1 | min: 1 | 年份(塔罗斯历) | '152' |
secret | text | 1 | — | 密级(占位:绝密) | '绝密' |
examType | text | 1 | — | 试卷类型(占位:A) | 'A' |
examDuration | text | 1 | — | 考试时间(占位:120分钟) | '120分钟' |
title | text | 2 | — | 试卷标题(占位:期末考试I卷) | '全文明环带普通高等学校招生统一考试' |
subject | text | 1 | — | 科目(占位:数学) | '数学' |
examInfo | kv-grid | 整行 | 键值对列表 | 附加考试信息 | [{ key: '命题组', value: '终末地工业' }] |
showAnswer | toggle | 1 | — | 显示答案 | false |
parJustify | toggle | 1 | — | 段落两端对齐 | true |
showScoreBox | toggle | 1 | — | 显示评分框 | true |
files | file-list | 整行 | 内置 6.png、17.png | 文件列表 | 两个内置素材 |
docContent | textarea | 整行 | grow: true, minHeight: 40 | 试卷内容 | 内置完整数学示例卷 |
字段声明的实际代码(节选)展示了选项与内置文件是如何绑定的:
1{
2 type: 'select',
3 key: 'issuer',
4 label: () => m.issuer(),
5 placeholder: () => m.select_issuer(),
6 options: ISSUERS.map((i) => ({
7 value: i.key,
8 label: () => m[`issuer_${i.key}`]()
9 })),
10 colspan: 1
11},1{
2 type: 'file-list',
3 key: 'files',
4 label: () => m.file_list(),
5 defaultFiles: [
6 { name: '6.png', url: img6 },
7 { name: '17.png', url: img17 }
8 ]
9},Sources:
defaultFiles 的语义由契约层定义:每个条目包含 name(写入 VFS 的文件名)和 url(Vite 静态资源导入),首次使用模板时自动预载(见 types.ts)。这就是为什么默认试卷内容能直接引用 image("6.png", ...) 与 image("17.png", ...)。
textarea 字段上的 grow: true 与 minHeight: 40(Tailwind min-h-40)让"试卷内容"编辑区占满表单剩余高度,反映该字段是本模板的主工作区(见 types.ts)。
核心流程:从表单值到 Typst 源码
generateTypstSource 的实际实现(节选):
1generateTypstSource: (values: Record<string, unknown>) => {
2 const v = values as unknown as TestpaperValues;
3 const year = parseYear(v.year);
4 const fullTitle = `${year}年${v.title}`;
5
6 const kvEntries = (v.examInfo ?? [])
7 .filter((e: KvEntry) => e.key.trim() !== '')
8 .map((e: KvEntry) => `${escapeTypst(e.key)}: "${escapeTypst(e.value)}"`)
9 .join(', ');
10
11 const watermarkExt = issuerExt(v.issuer);
12 const watermarkScale = getLogoScales()[v.issuer] ?? 1;
13 const isA4 = v.paperSize === 'a4';
14 const watermarkWidth = isA4 ? '40%' : '20%';
15 const paperVar = isA4 ? 'a4' : 'a3';
16
17 const lines: string[] = [
18 '#import "@this/ezexam:0.3.2": *',
19 '',
20 '#show: setup.with(',
21 ' mode: EXAM,',
22 ` paper: ${paperVar},`,
23 ` show-answer: ${v.showAnswer ? 'true' : 'false'},`,
24 ` par-justify: ${v.parJustify ? 'true' : 'false'},`,
25 ')',
26 '',
27 `#set page(background: place(center + horizon, block(width: ${watermarkWidth}, image("watermark-${v.issuer}.${watermarkExt}", width: ${watermarkScale} * 100%))))`,
28 '',
29 `#chapter[${escapeTypst(fullTitle)}]`,
30 `#title[${escapeTypst(fullTitle)}]`,
31 `#subject[${escapeTypst(v.subject)}]`
32 ];Source: testpaper.ts
生成的 Typst 结构逐段解析
- 包导入与全局设置:
#import "@this/ezexam:0.3.2": *引入考试排版包,随后#show: setup.with(mode: EXAM, paper: a3|a4, show-answer: ..., par-justify: ...)一次性把三个开关(showAnswer、parJustify)和纸张尺寸映射为包级配置。这样把布尔开关留在setup层而非逐题控制,保证"显示答案"切换能全局生效。 - 水印:通过
#set page(background: ...)在页面中心水平线放置watermark-{issuer}.{ext}图片。A4 纸张水印占宽40%,A3 只占20%(A3 面积更大,等比放大时需更小比例);getLogoScales()提供每个机构 logo 的额外缩放系数,缺失时回退1。 - 标题三连:
#chapter、#title、#subject均使用escapeTypst(fullTitle),标题统一拼接为{year}年{title},其中年份已经parseYear规范化。 - 条件段落:后续代码按用户输入条件性地追加头部元素。
条件追加逻辑的完整代码:
1if (v.secret.trim()) {
2 lines.push(`#secret(body: [${escapeTypst(v.secret)}★启用前])`);
3}
4
5if (v.showScoreBox) {
6 lines.push('#score-box(y: .5in)');
7}
8
9if (v.examType.trim()) {
10 lines.push(`#exam-type[${escapeTypst(v.examType)}]`);
11}
12
13if (kvEntries) {
14 lines.push(`#exam-info(info: (${kvEntries}))`);
15}
16
17if (v.examDuration.trim()) {
18 lines.push(
19 `#let exam-duration = "${escapeTypst(v.examDuration)}"`,
20 '#exam-info(info: (考试时间: [#exam-duration], 总分: [#total-pts 分]))'
21 );
22} else {
23 lines.push(`#exam-info(info: (总分: [#total-pts 分]))`);
24}
25
26lines.push('', v.docContent);
27
28return lines.join('\n');Source: testpaper.ts
设计意图解读:
- 空值即省略:
secret、examType、examDuration为空时对应行直接不生成,而非生成空标记,避免 Typst 渲染出空标题或空信息条。 examInfo与examDuration的双轨合并:用户自定义的 KV 项生成第一条#exam-info;若有考试时长则再生成第二条包含考试时间与总分的信息条。总分引用#total-pts——这是 ezexam 包在编译期对全文题目分数求和的变量,保证总分自动统计,无需用户手算。docContent原样透传:试卷正文不做任何转义或变换,保持 Typst DSL 的完整表达能力(用户可使用#question、#choices、#fillin、#set-default-pts、#text-figure等 ezexam 函数)。转义只发生在"元数据字段"(标题、科目等)上,这是刻意的分层:元数据是纯文本,正文是代码。
默认试卷内容(内置示例)
defaults() 返回的 docContent 是一份完整的数学试卷(testpaper.ts),覆盖 ezexam 的核心 API 用法:
1#notice(
2 [答题前,请务必将自已的姓名、准考证号用0.5毫米黑色墨水的签字笔填写在试卷及答题卡的规定位置。],
3 [请认真核对监考员在答题卡上所粘贴的条形码上的姓名、准考证号与本人是否相符。],
4 ...
5)
6
7#set-default-pts(5)
8= 单选题:本题共 #q-count 小题,每小题 #single-pts 分,共 #section-pts 分。...
9#question[
10 $(1 + 5"i")"i"$ 的虚部为 #paren[C]
11 #choices(-1, 0, 1, 6)
12]Source: testpaper.ts
示例卷展示的模式与对应 ezexam 语义:
| 模式 | 示例 | 作用 |
|---|---|---|
#set-default-pts(n) | #set-default-pts(5)、#set-default-pts(none) | 设置该节之后每题默认分值,none 表示按题显式给分 |
= 章节标题 | = 填空题:...共 #section-pts 分 | 一级标题即大题,#q-count/#single-pts/#section-pts 由包自动计算 |
#question[...] | 含 points: 13, bottom: 1in 参数 | 一道题;points 显式覆盖默认分,bottom 预留答题空间 |
#choices(...) / #choices(columns: 1, ...) | 单选/多选题选项 | 自动排布选项,columns: 1 强制单列(用于长选项) |
#paren[C] / #fillin[4] | 标注正确答案 / 填空答案 | 配合 showAnswer 开关决定是否渲染答案 |
#text-figure(...) | image("6.png", height: 1.5in) 题旁配图 | 图文混排,图片来自 files 字段的 VFS |
#table(...) | 解答题中的列联表 | 原生 Typst 表格嵌入题目 |
这份内置示例同时承担三重职责:功能演示(教用户 ezexam DSL 用法)、回归基准(编译通过即说明模板与包版本兼容)、开箱体验(新用户首次打开即看到成品试卷)。
API 参考
escapeTypst(s: string): string
转义反斜杠与双引号(\ → \\," → \"),用于把用户输入安全嵌入 Typst 字符串字面量或内容块。
const escapeTypst = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');Source: testpaper.ts
注意:它只处理两种字符,[、]、# 等其余 Typst 语法字符不转义——这正是"正文不转义、元数据可含中文"策略的边界(详见"边界与容错")。
parseYear(raw: string): number
解析塔罗斯历年份并兜底:
1const parseYear = (raw: string): number => {
2 const n = parseInt(raw, 10);
3 return isNaN(n) || n <= 0 ? 152 : n;
4};Source: testpaper.ts
参数:raw — 表单中的年份文本。
返回:合法正整数年份;NaN、非正数或空串时回退到默认值 152。
testpaperTemplate: TemplateDefinition
导出的模板定义对象,包含 id、name()、gridCols、storageVersion、fields、defaults()、generateTypstSource(values)、getFileName(values)。
getFileName(values): string
1getFileName: (values: Record<string, unknown>) => {
2 const v = values as unknown as TestpaperValues;
3 const year = parseYear(v.year);
4 return `${year}年${v.title} ${v.subject}.pdf`;
5}Source: testpaper.ts
对默认值而言生成 152年全文明环带普通高等学校招生统一考试 数学.pdf。年份同样走 parseYear 兜底,确保下载文件名永不含 NaN。
边界与容错
| 场景 | 处理方式 | 代码位置 |
|---|---|---|
| 年份非法(空/NaN/≤0) | parseYear 回退 152,标题与文件名均不受污染 | L27-L30 |
examInfo 中某行 key 为空白 | filter(e => e.key.trim() !== '') 剔除,避免生成 " : "..." 无效键 | L314-L317 |
examInfo 为 undefined(旧版本存储) | (v.examInfo ?? []) 空数组兜底,kvEntries 为空串时不生成该行 | L314 |
| 密级/试卷类型/时长留空 | trim() 判断后整行省略 | L342-L365 |
| 机构 logo 缺少缩放配置 | getLogoScales()[v.issuer] ?? 1 回退原尺寸 | L320 |
元数据含 " 或 \ | escapeTypst 转义,防止破坏 Typst 字符串 | L25 |
| 存储结构变更 | storageVersion: 3 由通用存储层用于触发迁移/失效 | L36 |
docContent 含 Typst 语法错误 | 模板层不做校验,错误在 Typst 编译阶段暴露(预览面板呈现) | — |
一致性要点:generateTypstSource 与 getFileName 各自独立调用 parseYear,因此两处年份永远一致;布尔开关用三元生成字面量 true/false,不经过 String(value) 以避免意外值(如 "false" 字符串被误判)。
国际化
所有 UI 文案通过 paraglide 的 m.* 访问器惰性取值,键集中在 messages/zh.json(template_testpaper 到 testpaper_year 共约 18 个键)。label 全部是 () => string 函数而非字符串,使语言切换时表单标签能即时重渲染而无需重建模板对象。唯一硬编码的中文是 #exam-info 行内的"考试时间/总分"前缀(L361),它们属于生成的 Typst 文档正文而非 UI 文案,因此不参与 i18n。
扩展点
- 新增表单能力:若需新字段类型(如日期),应扩展 types.ts 的
FormField联合类型与通用渲染器,再在本模板fields中声明——模板文件本身无需改动结构。 - 新增题型/样式:题面样式(如答题卡版式)由
@this/ezexam包版本决定;升级只需修改导入行的版本号0.3.2(L326),但需同步验证内置示例卷仍可编译。 - 新增水印机构:在
ISSUERS注册并在getLogoScales补充缩放即可,本模板的issuer选择框与水印行会自动适配,无需修改模板代码。 - 调整默认内容:修改
defaults().docContent后应递增storageVersion,避免旧用户已保存的草稿与新结构混淆。
相关链接
- 模板契约:src/lib/templates/types.ts
- 机构常量与水印缩放:src/lib/constants.ts
- KV 网格组件(
KvEntry来源):src/lib/components/KvGrid.svelte - 文档库 UI 图标映射:src/lib/components/DocLibrary.svelte
- 中文文案表:messages/zh.json
- 兄弟模板:公文模板(
src/lib/templates/official-doc.ts,见对应目录页面)