☰
可视化答题卡编辑器:从JSON建模到毫米级精确打印
2026/10/20 8:03:50 网站建设 项目流程

简介:这是一套基于网页技术的答题卡可视化制作工具源码,主要面向教育、培训、考试场景中需要快速定制答题卡的用户,以及希望学习前端交互实现的中级开发者。源码提供所见即所得的编辑界面,使用者无需编程基础即可完成题干、选项和答案区域的布局与内容设计,也适合作为软件/插件集成进现有业务系统。包内共55个文件,以37个JS脚本、15个CSS样式表和3个HTML页面为主,分别承担动态交互、视觉样式和页面结构,压缩后约718KB,目录清晰便于定位。目前已有525人学习下载。除直接部署使用外,项目还完整覆盖了前端开发中的多项关键技能:拖动排序、单选多选逻辑、表单校验、JSON数据保存与恢复、Canvas/SVG绘图,以及主流浏览器兼容处理。对于希望研究可视化编辑器原理、在线表单或答题类应用的中高级开发者,这是一份可直接对照学习和二次开发的实用源码。

1. 可视化在线制作答题卡:为什么说答题卡是排出来的,不是画出来的

一张卷子最容易让一个线上考试系统翻车的地方,不是试卷编辑器,而是答题卡。前阵子一位做小考试系统的朋友给我看他们打印出来的样卡:填涂方块肉眼看着整整齐齐,放进扫描仪后识别率只有六成,问题就出在格子的实际坐标偏了零点几毫米。这种现象在 web 端答题卡制作源码、可视化在线制作这个方向里,是最常见也最值得提前绕开的坑。本文不打算给一个现成源码包,而是讲清楚自建一个可视化答题卡编辑器从数据模型、拖拽画布、精确打印到自检验证的完整落地路径。适合正在做在线考试、问卷采集或纸质表单数字化的人,也适合想把「拖拽生成 + 毫米级排版」这套能力复用到其他票据、工单场景的开发者。

2. 答题卡数据模型设计:用一份 JSON 描述整张卡的结构

2.1 答题卡的结构拆解:头部字段、题型区块与定位标记

先把一张标准答题卡拆开看。它跟普通网页表单最大的区别是:最终产物是纸张,判定者是扫描识别程序,所以结构必须严格分层。从视觉上可以分成三块。

第一块是学生信息区,包含姓名、学号、班级这些字段。每个字段右侧都跟着一串「填涂格」,学号通常有 10 到 12 个格子,姓名有 6 到 10 个格子。这些格子不是让手写的,是让考生用铅笔涂黑的,每个格子上方可能标着数字或字母候选。第二块是题目区域,由若干「题型区块」组成,比如单选题一个区块、多选题一个区块、判断题一个区块。每个区块有固定的题号区间、固定的选项数量、固定的排列方式,同一区块里的每道题结构完全一致。第三块是定位标记,通常放在纸张四角或两侧,是纯黑色方块,扫描仪靠它们识别纸张位置、倾斜角度和缩放比例。

这三块结构就是数据模型的核心。设计时不是按「像素坐标」去存每个元素的位置,而是按「相对布局 + 排版参数」来存。答题卡的每个区块都是规则的矩阵,只需要题号范围、选项数量、每行列数、单元格尺寸,就能算出所有格子的位置。

2.2 可序列化的 JSON 规范:把结构写进数据

我一般用一份嵌套 JSON 来描述整张答题卡,所有长度单位统一用毫米,不用像素。这样一份数据既能直接驱动渲染,也能导出为模板文件,还能在后端存档复用。

// answer-card-spec.json { "paper": { "width": 210, "height": 297, "margin": 8 }, "headerFields": [ { "field": "studentName", "label": "姓名", "cells": 8 }, { "field": "studentNo", "label": "学号", "cells": 12 }, { "field": "className", "label": "班级", "cells": 6 } ], "blocks": [ { "id": "block-single-1", "title": "一、单选题(每题2分)", "type": "single", "questionStart": 1, "questionCount": 20, "optionCount": 4, "columns": 4, "cellWidth": 6, "cellHeight": 5, "optionLabels": ["A", "B", "C", "D"] }, { "id": "block-multi-1", "title": "二、多选题(每题3分)", "type": "multi", "questionStart": 21, "questionCount": 10, "optionCount": 5, "columns": 2, "cellWidth": 6, "cellHeight": 5, "optionLabels": ["A", "B", "C", "D", "E"] } ], "cornerMarks": [ { "x": 6, "y": 6, "size": 8 }, { "x": 196, "y": 6, "size": 8 }, { "x": 6, "y": 283, "size": 8 }, { "x": 196, "y": 283, "size": 8 } ] }

这份 JSON 里有两个设计决策值得说明。第一,blocks用数组而不是用命名对象,因为题目区块是有顺序的,数组天然保留顺序。第二,cornerMarks是独立配置,不挂到某个区块下面,因为定位标记的位置是相对整张纸算的,不能因为删了一个题型区块就跟着消失。cellWidth和cellHeight单独拆出来,是因为不同题型可以有不同的格子密度,单选题一行放 4 题,判断题可能要一行放 6 题,把尺寸做成参数才能灵活调。

2.3 从 JSON 到 DOM:渲染函数的核心逻辑

有了 JSON 之后,渲染是水到渠成的事。我用原生 JavaScript 写一个renderCard(spec, container)函数,把每个区块逐层构建成 DOM 节点。核心思路是全量重渲染:数据变了就整个画布重新生成,答题卡的节点数量级通常在几百上千,全量重建不会带来性能压力,但能省掉大量『找节点、改样式、对坐标』的麻烦。

function renderCard(spec, container) { container.innerHTML = ''; container.style.width = spec.paper.width + 'mm'; container.style.height = spec.paper.height + 'mm'; // 渲染头部信息区:姓名/学号/班级 + 填涂格 const header = document.createElement('div'); header.className = 'card-header'; spec.headerFields.forEach(item => { const field = document.createElement('div'); field.className = 'header-field'; const label = document.createElement('span'); label.className = 'header-label'; label.textContent = item.label; field.appendChild(label); const cellGroup = document.createElement('div'); cellGroup.className = 'header-cells'; for (let i = 0; i < item.cells; i++) { const cell = document.createElement('span'); cell.className = 'fill-cell'; cell.style.width = '5mm'; cell.style.height = '5mm'; cellGroup.appendChild(cell); } field.appendChild(cellGroup); header.appendChild(field); }); container.appendChild(header); // 渲染题型区块 spec.blocks.forEach(block => { const section = document.createElement('div'); section.className = 'card-section'; section.dataset.blockId = block.id; const title = document.createElement('div'); title.className = 'section-title'; title.textContent = block.title; section.appendChild(title); for (let i = 0; i < block.questionCount; i++) { const row = document.createElement('div'); row.className = 'question-row'; const no = document.createElement('span'); no.className = 'question-no'; no.textContent = block.questionStart + i; row.appendChild(no); const optionGroup = document.createElement('div'); optionGroup.className = 'option-group'; optionGroup.style.display = 'inline-block'; block.optionLabels.forEach(label => { const bubble = document.createElement('span'); bubble.className = 'option-bubble'; bubble.style.width = block.cellWidth + 'mm'; bubble.style.height = block.cellHeight + 'mm'; bubble.textContent = label; optionGroup.appendChild(bubble); }); row.appendChild(optionGroup); section.appendChild(row); } container.appendChild(section); }); // 渲染四角定位黑块 spec.cornerMarks.forEach(m => { const mark = document.createElement('div'); mark.className = 'corner-mark'; mark.style.left = m.x + 'mm'; mark.style.top = m.y + 'mm'; mark.style.width = m.size + 'mm'; mark.style.height = m.size + 'mm'; container.appendChild(mark); }); }

函数里所有节点尺寸都直接拼接mm单位写进style,这是答题卡渲染和普通网页渲染的关键差异,浏览器的盒模型会把这套尺寸原样传递到打印输出。container需要提前设置position: relative,否则四角黑块会跑到页面左上角去。这个函数渲染出的每一个 DOM 节点都被后面要实现的选中、拖拽、属性修改逻辑复用,所以dataset.blockId这种数据标记从一开始就要埋好,否则后面做事件委托时找不到「点的是哪个区块」。

3. 可视化编辑器核心实现:拖拽放置、网格吸附与属性联动

3.1 编辑器三栏布局:组件面板、画布与属性面板的分工

可视化编辑器的界面布局,我直接用最经典的三栏结构:左栏是组件面板,中间是画布,右栏是属性面板。这个布局在问卷系统、表单设计器、电子名片编辑器里都是通用的,答题卡编辑器没必要另辟蹊径。

左栏组件面板里放的是「题型组件」和「结构组件」两类。题型组件包括单选题区块、多选题区块、判断题区块,拖到画布上会生成一个完整的题型区块;结构组件包括定位标记、头部字段、分隔线。中间画布是核心区域,上面渲染的就是上一章那份 JSON,用户看到的效果和最后打印出来的基本一致。右栏属性面板平时是空的,用户点选画布上的某个区块后,属性面板才会加载这个区块的参数,包括题号起始、题目数量、选项数量、每行列数、格子尺寸。

三栏之间的数据流是单向的:组件面板和属性面板的操作都只修改 JSON,修改完触发renderCard重绘画布,画布本身不直接改数据。这个单向数据流是这套编辑器不越写越乱的基础,不管是拖入组件还是调整参数,最后都归结为「更新 JSON,再重渲染」。

3.2 拖拽添加与像素毫米换算:组件放进画布的正确姿势

拖拽添加组件用 HTML5 原生拖放事件就能实现,不需要引入拖拽库。组件面板里的每个组件卡片设置draggable="true",拖拽时把组件类型写进dataTransfer,画布监听dragover和drop完成放置。

这里最容易出错的是坐标换算。浏览器里拿到的是clientX像素,而数据模型用的是毫米,必须先把像素换算成毫米再写入 JSON。换算公式不复杂:毫米 = 像素 × (纸张宽度毫米 / 画布的实际渲染宽度像素),画布的渲染宽度用getBoundingClientRect()实时取。

// canvas 是画布 DOM 节点 canvas.addEventListener('dragover', (e) => { e.preventDefault(); // 必须阻止默认行为,否则 drop 不会触发 }); canvas.addEventListener('drop', (e) => { e.preventDefault(); const componentType = e.dataTransfer.getData('text/plain'); if (!componentType) return; const rect = canvas.getBoundingClientRect(); // 关键换算:像素 -> 毫米 const mmPerPxX = 210 / rect.width; const mmPerPxY = 297 / rect.height; const xMm = (e.clientX - rect.left) * mmPerPxX; const yMm = (e.clientY - rect.top) * mmPerPxY; // 网格吸附,步长取 2mm,保证落点是偶数,打印时更稳定 const snapped = snapToGrid(xMm, yMm, 2); const block = createBlockByType(componentType, snapped.x, snapped.y); spec.blocks.push(block); renderCard(spec, canvas); });

网格吸附函数是最值得多写两行的部分。答题卡的格子讲究对齐,拖到哪里就放哪里会留下一堆小数点后三位的坐标,打印缩放时这些小数就是错位的来源。吸附到 2 毫米的网格上,既能保证布局匀称,也方便后面手动微调坐标。

function snapToGrid(x, y, step) { return { x: Math.round(x / step) * step, y: Math.round(y / step) * step }; }

放置时还有一个小细节:拖入的组件如果落在画布边缘,要自动修正到页面边距内。常见做法是拿paper.margin跟吸附后的坐标做比较,越界就钳制到边界值。不做这一步,用户从组件面板拖到画布时,很容易把组件放在 A4 纸外,打印出来少一截,还找不到原因。

3.3 属性面板与画布联动:改一个参数全卡重排

属性面板的联动,我采用「点击选中 + 表单修改 + 全量重渲染」三步。画布上的每个题型区块渲染时都带上了>canvas.addEventListener('click', (e) => { const sectionEl = e.target.closest('.card-section'); if (!sectionEl) return; selectedBlockId = sectionEl.dataset.blockId; const block = spec.blocks.find(b => b.id === selectedBlockId); if (!block) return; // 把区块参数填进属性面板的输入框 document.querySelector('[data-prop="questionStart"]').value = block.questionStart; document.querySelector('[data-prop="questionCount"]').value = block.questionCount; document.querySelector('[data-prop="optionCount"]').value = block.optionCount; document.querySelector('[data-prop="columns"]').value = block.columns; document.querySelector('[data-prop="cellWidth"]').value = block.cellWidth; document.querySelector('[data-prop="cellHeight"]').value = block.cellHeight; });

属性面板里的输入框统一监听change事件,修改一个参数就走一遍更新流程。这里有一个很实用的约束:修改optionCount时,要同步重置optionLabels,否则会出现选项数量变成 5 但标签还是 ABCD 的错位情况。

propertyPanel.addEventListener('change', (e) => { if (!selectedBlockId) return; const block = spec.blocks.find(b => b.id === selectedBlockId); if (!block) return; const key = e.target.dataset.prop; if (key === 'optionCount') { block.optionCount = Number(e.target.value); block.optionLabels = generateLabels(block.optionCount); } else if (key in block) { block[key] = Number(e.target.value); } renderCard(spec, canvas); // 改完立即全量重绘 });

renderCard放在每一次变更后直接调用,这是这个编辑器「玄学」最少的地方之一。有人会尝试做局部 DOM 更新来提升性能,但答题卡编辑器的瓶颈根本不在渲染,而在排版参数的连锁反应:改了columns,整行布局都得重排;改了cellWidth,后面所有区块的位置都要顺移。局部更新在这种连锁场景下要维护的边界条件太多,全量重绘一行代码解决,体感上也不觉得慢。

4. 打印与 PDF 导出:让毫米单位的答题卡准确落纸

4.1 为什么答题卡必须带定位标记:OMR 扫描的基本前提

答题卡做出来不是拿在手里看的,是放进扫描仪或者拍照识别程序里用的。识别程序拿到一张倾斜了 2 度的图片时,怎么知道这张纸歪了?靠的就是四角的黑色定位块。这看起来只是几个黑方块,实际上是整个识别流程的坐标系基准。

定位标记的尺寸和位置有讲究。常见的做法是在 A4 四个角放置 8mm×8mm 的实心黑块,距离纸边 6mm。这个位置是有意避开的:纸张在印刷、裁切、运输过程中最容易损伤的就是边缘,而识别程序会把黑块外围 1mm 的区域作为「留白保护带」,这个保护带里不能有任何填涂格、题号或者文字线条。很多做答题卡的新手把题号印得离黑块太近,识别程序一算区域,发现黑块和题号粘连,整张卡的坐标全部跑偏。

4.2 用 CSS 精确控制 A4 输出:@page、mm 与打印缩放

打印输出是答题卡编辑器最核心的一环。屏幕上显示得再漂亮,打印时页面边距、缩放比例任何一个不对,毫米级的对齐就全毁了。我用 CSS 控制打印规则的固定写法如下。

.card { box-sizing: border-box; background: #ffffff; position: relative; font-family: Arial, "Microsoft YaHei", sans-serif; } .corner-mark { position: absolute; background: #000000; } @media print { @page { size: A4 portrait; margin: 0; } body { margin: 0; } .editor-panel, .property-panel, .component-palette { display: none !important; } .card { width: 210mm; height: 297mm; margin: 0; box-shadow: none; } }

这里三个点缺一不可。第一,@page的margin必须设为 0,否则浏览器会在已有 210mm 宽的卡片外面再加一层默认页边距,右边和下边直接溢出。第二,body的margin也要清零,这是浏览器默认样式带着的 8px,常被忽略。第三,打印对话框里的缩放比例必须选 100%,如果选了「适合页面」或者「缩小到页边距」,浏览器会自动缩放内容,毫米坐标瞬间失真。

4.3 两种 PDF 导出方案:浏览器打印与 canvas 截图

导出 PDF 常用做法有两条路。第一条是用浏览器自带的打印能力,用户在打印对话框中选择「另存为 PDF」;第二条是用html2canvas把页面截图成图片,再用jsPDF把图片写进 PDF。两条路的适用场景完全不一样。

浏览器打印导出的最大优势是矢量输出,文字锐利、线条清晰、代码量几乎为零,缺点是必须依赖用户的打印对话框操作。canvas 截图导出适合做自动化批量导出,比如考试系统后台需要一次导出几十套答题卡模板,但它是位图输出,遇到了缩放就需要额外处理清晰度。

// 方案 A:浏览器打印,打印对话框里选「另存为 PDF」 function printCard() { window.print(); } // 方案 B:html2canvas + jsPDF,适合批量导出 function exportCardAsPdf() { const card = document.querySelector('.card'); html2canvas(card, { scale: 2, // 用 2 倍缩放,避免文字边缘发虚 backgroundColor: '#ffffff', logging: false, windowWidth: card.scrollWidth, windowHeight: card.scrollHeight }).then(canvas => { const imgData = canvas.toDataURL('image/jpeg', 0.95); const pdf = new jsPDF('p', 'mm', 'a4'); pdf.addImage(imgData, 'JPEG', 0, 0, 210, 297); pdf.save('answer-card.pdf'); }); }

html2canvas的scale参数是踩坑高发区。取值 1 时,导出的图片在 300 DPI 的屏幕上放大看全是锯齿;取值太大,比如 4,则导出过程会卡顿甚至内存溢出。我一般取 2,足够应付打印需求。windowWidth和windowHeight如果不传,html2canvas 会默认使用视口尺寸,页面里有滚动条时导出结果会被截掉一部分,这个参数必须显式指定为画布的实际尺寸。

5. 答题卡制作避坑手册:4 个隔着屏幕看不见的翻车点

5.1 屏幕预览完美,打印出来右边溢出

现象:浏览器里答题卡整整齐齐居中显示,A4 打印出来右侧的内容明显被切掉了一截,底部的定位黑块也看不到。

原因:@page没有设置margin: 0,浏览器在 210mm 宽的卡片外面又加了一圈默认页边距;同时打印对话框默认选择了「适合页面」缩放,浏览器为了把超宽内容塞进页面,按比例缩小了整张卡,毫米尺寸全部失真。

解决:先确认三条规则都到位——@page { size: A4; margin: 0; }、body { margin: 0; }、打印缩放选 100%。我自己的习惯是先打一张测试页,用尺子量打印出来的卡片宽度,应该正好 210mm。不是 210mm 就说明缩放设置有残留,优先检查浏览器的打印设置里是否开启了「缩放以适合页面」。

5.2 填涂方块肉眼看着齐,扫描识别率只有六成

现象:答题卡打印出来肉眼检查没有问题,填涂格横平竖直,但用扫描仪或手机拍照识别,大量题目的涂点位置对不上,识别率只有百分之六七十。

原因:填涂格子尺寸用了奇数毫米。比如cellWidth: 5,在 600 DPI 打印下换算成像素是 118.11,四舍五入会产生半像素渲染,肉眼看不出来,但识别程序计算涂点重心时就会偏移。另一个常见原因是填涂格与相邻内容之间的间距不一致,导致识别程序按固定间距找格子时错位。

解决:所有格子尺寸、间距统一取偶数毫米。这是我反复验证过的经验:6mm 格子、2mm 间距、2mm 步长的网格吸附,在 300 DPI 和 600 DPI 下都能整除,打印最稳定。另外把网格吸附的步长从 1mm 改成 2mm,强制所有坐标落在偶数上,能挡住大部分半像素问题。

5.3 导出 PDF 后中文字符变成方块

现象:在 Windows 上用浏览器打印导出 PDF,内容正常;把 PDF 发给用 macOS 或者手机的人,标题里的「单选题」几个字全变成方框。

原因:PDF 文件里引用了宿主系统的字体,但没有嵌入字体子集。Windows 上用的「微软雅黑」在 macOS 和手机上不存在,PDF 阅读器找不到字体就渲染成方块。

解决:如果走浏览器打印导出,把字体设置成跨平台系统字体,比如正文用 Arial、中文用「思源黑体 或 苹方」这类多平台字体。如果走html2canvas + jsPDF的方案,文字渲染前已经被转成图片,反而没有字体问题。我的建议是:面向陌生用户分发的 PDF 统一用 canvas 截图方案,面向内部打印的用浏览器打印方案。

5.4 拖拽排好的模板,刷新页面全部消失

现象:花了半小时拖好了十几个区块,调整好格子和间距,按了一下 F5,画布回到空白状态,所有布局全部消失。

原因:编辑器的数据只存在内存变量里,刷新后 JavaScript 重新执行,内存中的spec被清空,没有任何持久化机制。

解决:至少要在spec每次变更时同步写入localStorage,再提供一个「导出 JSON」按钮让用户手动下载备份。localStorage 的容量对一份答题卡 JSON 来说绰绰有余,这层自动保存是编辑器最低限度的后悔药。我自己的实现里会加一个版本号字段version: 1,加载时校验版本不匹配就提示用户,避免未来数据结构调整后旧模板加载失败。

function saveToLocal(spec) { localStorage.setItem('answer-card-spec', JSON.stringify(spec)); } function loadFromLocal() { const raw = localStorage.getItem('answer-card-spec'); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { console.error('本地模板解析失败', e); return null; } }

自动保存的触发时机建议放在每一次renderCard之后,不要放在属性面板的change事件里。因为后续如果增加了拖拽排序、复制区块这些操作,每个操作都要记得触发保存,统一挂在renderCard后面最不容易漏。加载时要做一层完整性检查,至少验证blocks数组存在且每一项有id和type字段,缺少就丢弃并提示用户重新创建。

6. 排版自检脚本:上打印机之前先用代码验一遍

6.1 自检脚本的核心检查项

打印一张答题卡的成本不只是纸张和墨水,还有来回调整的时间。我给自己定了个习惯:所有模板导出打印前,先在浏览器控制台跑一遍自检函数。这个函数检查两件事,一是所有填涂格是否都在页面边界内,二是区块之间是否存在重叠。

function validateLayout(spec) { const errors = []; const cardW = spec.paper.width; const cardH = spec.paper.height; // 检查所有填涂格是否在 A4 边界内 document.querySelectorAll('.option-bubble, .fill-cell').forEach(el => { const rect = el.getBoundingClientRect(); const cardRect = canvas.getBoundingClientRect(); const left = (rect.left - cardRect.left) / cardRect.width * cardW; const right = (rect.right - cardRect.left) / cardRect.width * cardW; const top = (rect.top - cardRect.top) / cardRect.height * cardH; const bottom = (rect.bottom - cardRect.top) / cardRect.height * cardH; if (left < 0 || right > cardW || top < 0 || bottom > cardH) { errors.push('元素超出边界: ' + el.textContent + ' (left=' + left.toFixed(1) + ', top=' + top.toFixed(1) + ')'); } }); // 检查相邻区块是否重叠 const sections = [...document.querySelectorAll('.card-section')]; for (let i = 0; i < sections.length; i++) { for (let j = i + 1; j < sections.length; j++) { const a = sections[i].getBoundingClientRect(); const b = sections[j].getBoundingClientRect(); const overlap = !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom); if (overlap) { errors.push('区块重叠: ' + sections[i].dataset.blockId + ' 和 ' + sections[j].dataset.blockId); } } } if (errors.length === 0) { console.log('自检通过,可以打印'); } else { console.table(errors); } return errors; }

脚本里所有坐标都先换算回毫米再比较,避免直接拿像素值判断。因为画布在屏幕上的渲染尺寸会因为窗口缩放而变化,而毫米才是数据模型和打印输出的真实坐标系。console.table输出的错误列表会让问题一目了然。

6.2 把自检跑进日常工作流

我还会在编辑器的「导出打印」按钮里加一道拦截:点击打印前先跑自检,有错误就弹提示并列出问题项,没有错误才调用window.print()。这个看似简单的拦截,帮我挡住了大量「打印出来才发现某个区块挤出了页边距」的返工。答题卡编辑器做出来后,几乎每个版本都在改题型参数,多一道自动化体检,比任何代码审查都直观。自检脚本同时也是一份活的文档,新人接手这个编辑器时,跑一遍就知道哪些区域容易出问题,不用从头啃渲染逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询