Repository Wiki
Naptie/endfield-docmaker

试卷模板

试卷模板(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)。

架构

Loading diagram...

架构要点:

  • 契约驱动: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 描述:

typescript
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)默认值
issuerselect1ISSUERS 全部机构颁发机构ISSUERS[0].key
paperSizeselect1a3 / a4纸张大小'a3'
yearnumber1min: 1年份(塔罗斯历)'152'
secrettext1—密级(占位:绝密)'绝密'
examTypetext1—试卷类型(占位:A)'A'
examDurationtext1—考试时间(占位:120分钟)'120分钟'
titletext2—试卷标题(占位:期末考试I卷)'全文明环带普通高等学校招生统一考试'
subjecttext1—科目(占位:数学)'数学'
examInfokv-grid整行键值对列表附加考试信息[{ key: '命题组', value: '终末地工业' }]
showAnswertoggle1—显示答案false
parJustifytoggle1—段落两端对齐true
showScoreBoxtoggle1—显示评分框true
filesfile-list整行内置 6.png、17.png文件列表两个内置素材
docContenttextarea整行grow: true, minHeight: 40试卷内容内置完整数学示例卷

字段声明的实际代码(节选)展示了选项与内置文件是如何绑定的:

typescript
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},
typescript
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 源码

Loading diagram...

generateTypstSource 的实际实现(节选):

typescript
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 结构逐段解析

  1. 包导入与全局设置:#import "@this/ezexam:0.3.2": * 引入考试排版包,随后 #show: setup.with(mode: EXAM, paper: a3|a4, show-answer: ..., par-justify: ...) 一次性把三个开关(showAnswer、parJustify)和纸张尺寸映射为包级配置。这样把布尔开关留在 setup 层而非逐题控制,保证"显示答案"切换能全局生效。
  2. 水印:通过 #set page(background: ...) 在页面中心水平线放置 watermark-{issuer}.{ext} 图片。A4 纸张水印占宽 40%,A3 只占 20%(A3 面积更大,等比放大时需更小比例);getLogoScales() 提供每个机构 logo 的额外缩放系数,缺失时回退 1。
  3. 标题三连:#chapter、#title、#subject 均使用 escapeTypst(fullTitle),标题统一拼接为 {year}年{title},其中年份已经 parseYear 规范化。
  4. 条件段落:后续代码按用户输入条件性地追加头部元素。

条件追加逻辑的完整代码:

typescript
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 用法:

typst
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 字符串字面量或内容块。

typescript
const escapeTypst = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');

Source: testpaper.ts

注意:它只处理两种字符,[、]、# 等其余 Typst 语法字符不转义——这正是"正文不转义、元数据可含中文"策略的边界(详见"边界与容错")。

parseYear(raw: string): number

解析塔罗斯历年份并兜底:

typescript
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

typescript
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,避免旧用户已保存的草稿与新结构混淆。

相关链接

Sources

(2 files)