试卷 JSON 标准 v1
本文是前端编辑器、创建试卷接口、生成器和扫描器共同使用的数据标准。
核心原则:
- 每道小题是独立对象,不再用
columns / rows自动对齐整块选择题。 - 试卷名不写在 JSON 内;名称由数据库的
exam列和创建接口的name参数管理。 - 涂卡题使用
type: "mark",其他题使用type: "other"。 - 扫描不识别图像上的题号;结果按
questions[].id返回。 other题的region就是裁切区域,后续可直接裁给主观题处理流程。- 页面坐标统一使用
0..1比例,原点在左上角。 - 涂卡题的选项坐标相对小题区域,不相对整页。
机器可读版本见同目录的 exam.schema.json。完整可提交示例见 exam.example.json。
1. 完整示例
{
"schemaVersion": 1,
"unit": "ratio",
"paper": {
"name": "A4",
"width": 1240,
"height": 1754,
"physical": {
"width": 210,
"height": 297
},
"pdf": {
"width": 595.28,
"height": 841.89
}
},
"markers": [
{ "id": "top-left", "x": 0.03, "y": 0.03, "size": 0.035 },
{ "id": "top-right", "x": 0.935, "y": 0.03, "size": 0.035 },
{ "id": "bottom-left", "x": 0.03, "y": 0.935, "size": 0.035 },
{ "id": "bottom-right", "x": 0.935, "y": 0.935, "size": 0.035 }
],
"blocks": [
{
"id": "b1",
"type": "text",
"region": { "x": 0.08, "y": 0.05, "width": 0.55, "height": 0.05 },
"text": "高一数学第一次月考",
"style": {
"fontSize": 0.04,
"fontWeight": "bold",
"textAlign": "left"
}
},
{
"id": "b2",
"type": "text",
"region": { "x": 0.08, "y": 0.09, "width": 0.55, "height": 0.03 },
"text": "考试名称:____________________",
"style": { "fontSize": 0.014 }
},
{
"id": "b3",
"type": "text",
"region": { "x": 0.08, "y": 0.12, "width": 0.55, "height": 0.03 },
"text": "姓名:________ 班级:________",
"style": { "fontSize": 0.014 }
},
{
"id": "b4",
"type": "text",
"region": { "x": 0.45, "y": 0.12, "width": 0.5, "height": 0.08 },
"text": "注意事项:\n1. 请用 2B 铅笔填涂,修改时用橡皮擦干净。\n2. 每题只能填涂一个选项,多涂不得分。",
"style": {
"fontSize": 0.012,
"lineHeight": 1.5
}
}
],
"questions": [
{
"id": "q1",
"displayNumber": 1,
"type": "mark",
"region": {
"x": 0.06,
"y": 0.4,
"width": 0.38,
"height": 0.05
},
"choices": [
{ "value": "A", "region": { "x": 0, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "B", "region": { "x": 0.25, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "C", "region": { "x": 0.5, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "D", "region": { "x": 0.75, "y": 0, "width": 0.2, "height": 1 } }
]
},
{
"id": "q13",
"displayNumber": 13,
"type": "other",
"region": {
"x": 0.06,
"y": 0.6,
"width": 0.88,
"height": 0.18
}
}
],
"barcode": {
"id": "sheetCode",
"type": "code128",
"value": "20261109001",
"anchor": "top-right",
"offset": { "unit": "mm", "x": 22, "y": 10 },
"size": { "unit": "mm", "width": 45, "height": 12 },
"minSize": { "width": 30, "height": 8 }
},
"answerKey": {
"q1": "A"
}
}
2. 顶层字段
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
schemaVersion |
是 | 1 |
当前固定为 1 |
unit |
是 | "ratio" |
坐标单位,固定为页面比例 |
paper |
是 | object | 纸张和渲染尺寸 |
markers |
是 | array | 四角定位块,必须正好 4 个 |
questions |
是 | array | 小题列表,至少 1 个 |
blocks |
否 | array | 自定义展示内容块,不参与识别 |
barcode |
否 | object | 条形码布局 |
answerKey |
否 | object | 答案键,格式为 { "q1": "A" } |
试卷名不进入 JSON。它由创建接口的 name 参数写入数据库的 exam 列;同名创建直接返回 409,不会覆盖旧记录。
2.1 自定义内容块 blocks
title、examLine、infoLine、notes 这类固定字段不再进入标准。标题、考试信息、注意事项、说明文字等,全部由编辑者自己决定:
{
"blocks": [
{
"id": "b1",
"type": "text",
"region": { "x": 0.08, "y": 0.05, "width": 0.55, "height": 0.05 },
"text": "高一数学第一次月考",
"style": {
"fontSize": 0.04,
"fontWeight": "bold",
"textAlign": "left"
}
}
]
}
规则:
id是编辑器内部标识,可以随意取名,不要求语义,也不参与扫描;- 当前只支持
type: "text",后续可以扩展其他块类型; region使用页面比例,作者决定放在哪里、多大;text支持换行,注意事项可以写成一个多行块,也可以拆成多个块;style可选,支持fontSize(相对页面短边)、fontWeight、textAlign、lineHeight、color、fontFamily;blocks不属于questions,没有选项、没有答案键、不会进入识别结果;blocks[].id不能与任何questions[].id重复。
3. 纸张对象
{
"name": "A4",
"width": 1240,
"height": 1754,
"physical": { "width": 210, "height": 297 },
"pdf": { "width": 595.28, "height": 841.89 }
}
| 字段 | 说明 |
|---|---|
name |
纸张名,用于输出文件名,例如 答题卡-空白-A4.pdf |
width / height |
渲染画布像素尺寸,用于 SVG/PNG 预览 |
physical.width / physical.height |
实际纸张毫米尺寸,用于 mm 单位换算 |
pdf.width / pdf.height |
PDF 页面尺寸,单位是 PDF point |
4. 定位块
{
"id": "top-left",
"x": 0.03,
"y": 0.03,
"size": 0.035
}
id必须正好包含top-left、top-right、bottom-left、bottom-right。x、y是定位块左上角的页面比例。size是正方形边长相对页面短边的比例。- 定位块用于扫描时计算页面透视变换。
5. 小题对象
每道题都有独立的 id、type 和 region。题目之间没有全局行列关系。
5.1 公共字段
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
id |
是 | string | 全局唯一,例如 q1,扫描结果使用这个 key |
displayNumber |
否 | integer | 只用于打印显示;不参与识别 |
type |
是 | "mark" 或 "other" |
小题类型 |
region |
是 | object | 题目在页面中的区域 |
region:
{
"x": 0.06,
"y": 0.4,
"width": 0.38,
"height": 0.05
}
- 使用页面比例,原点左上角。
- 必须满足
x + width <= 1、y + height <= 1。 region同时是题目的整体定位区域;主观题直接拿它裁切。
5.2 涂卡题 type: "mark"
{
"id": "q1",
"displayNumber": 1,
"type": "mark",
"region": { "x": 0.06, "y": 0.4, "width": 0.38, "height": 0.05 },
"choices": [
{ "value": "A", "region": { "x": 0, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "B", "region": { "x": 0.25, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "C", "region": { "x": 0.5, "y": 0, "width": 0.2, "height": 1 } },
{ "value": "D", "region": { "x": 0.75, "y": 0, "width": 0.2, "height": 1 } }
]
}
choices[].region 相对 question.region,不是相对页面:
页面绝对区域 =
x: question.region.x + choice.region.x * question.region.width
y: question.region.y + choice.region.y * question.region.height
width: choice.region.width * question.region.width
height: choice.region.height * question.region.height
这样拖动整道题时,A/B/C/D 会一起移动。
注意:
- 一道涂卡题必须配置
choices;没有choices时只能判断“这个区域被涂过”,无法判断涂的是 A/B/C/D。 choices[].value在同一小题内不能重复。- 扫描时只采样每个
choice.region,不识别题号。 - 结果可能是
blank、single、multi、all,由检测配置决定阈值。
识别结果示例:
{
"q1": {
"type": "mark",
"answer": "A",
"mode": "single"
}
}
5.3 其他题 type: "other"
{
"id": "q13",
"displayNumber": 13,
"type": "other",
"region": { "x": 0.06, "y": 0.6, "width": 0.88, "height": 0.18 }
}
- 不配置
choices。 - 目前作为主观题处理:扫描时按
region裁切,不做答案识别。 - 后续大题的裁切、人工批改、OCR/大模型处理都可以直接使用这个区域。
- 输出示例:
{
"q13": {
"type": "other",
"crop": "q13.png"
}
}
6. 条形码
{
"id": "sheetCode",
"type": "code128",
"value": "20261109001",
"anchor": "top-right",
"offset": { "unit": "mm", "x": 22, "y": 10 },
"size": { "unit": "mm", "width": 45, "height": 12 },
"minSize": { "width": 30, "height": 8 }
}
anchor:top-left、top-right、bottom-left、bottom-right。offset:相对锚点的偏移;单位可以是mm或ratio。size:条形码尺寸;单位可以是mm或ratio。minSize:物理最小尺寸,单位固定为毫米;小于该尺寸时生成阶段报错。type默认code128,value是要编码的内容。
条形码的位置由“锚点 + 偏移 + 尺寸”解析,不依赖页面其他元素。
7. 答案键
答案键按小题 id 索引,不使用显示题号:
{
"answerKey": {
"q1": "A",
"q2": "B",
"q13": "C"
}
}
规则:
- 只能引用
type: "mark"的小题。 - 值必须存在于该题的
choices[].value中。 type: "other"的小题不进入答案键。
如果前端只做创建,不涉及批改,可以暂时省略 answerKey;生成标准答案或扫描批改时再补。
8. 校验规则
创建空试卷时校验:
name非空、1–100 字符、不能有首尾空白、不能包含 Windows 文件名非法字符。name在数据库中不能重复。
PUT 提交内容时校验:
paper、markers、questions完整;blocks可选。- 四个定位块 id 不重复且齐全。
- 每个
region在页面范围内,width/height > 0。 - 涂卡题至少有 2 个选项,选项值不重复。
questions[].id和blocks[].id全局唯一。blocks[].type当前只能是"text",文本不能为空。answerKey只能引用存在的涂卡题和存在的选项。- 顶层对象不允许未声明字段,避免前端拼错字段却被静默保存。
- JSON 顶层不允许出现
name;试卷名只在数据库和创建接口中维护。 - 不再接受
title、testNotice、examLine、infoLine、notes这类固定字段。
9. 前端编辑器的数据流
POST /api/v1/exams { name } 创建空试卷,拿到 id
→ GET /api/v1/exams/{id} 读取当前内容(初始为 {})
→ 编辑 paper / markers / questions / blocks
→ 涂卡题编辑 region 和 choices
→ 主观题只编辑 region
→ 内容块自定义 id、region、text、style
→ 可选编辑 barcode / answerKey
→ PUT /api/v1/exams/{id} 保存完整内容
→ POST /api/v1/files 上传待检查的答题卡图片,拿到 fileId
→ POST /api/v1/exams/check { examId, fileIds } 批量检查
编辑器不需要计算全局行列,不需要自动对齐,也不需要识别题号。每道题就是画布上的一个独立对象。
10. 当前实现状态
POST /api/v1/exams创建空试卷并去重。GET / PUT / DELETE /api/v1/exams/:id已实现。POST /api/v1/exams/check已按questions数组批量识别涂卡题;other题目前只返回类型,不做识别。- 仓库中现有的
generate / simulate仍读取旧的config/exam.json的fields网格。 - 后续把生成、填涂接入新标准时,统一以
questions数组为准;旧的fields只保留给现有/api/ocr测试流程。