成绩导入(R173 落地 → R174 改为三小问四列制) - app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件) · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值 · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝 · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号 - app/service/bigQuestion.js · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持") · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores - app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence) - app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载) 节点备份调度器(R168/R170) - app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间 · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻 · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源) - config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地 (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导) - app.js:启动构造单例 + hydrate 未到期计划 缺陷修复 - app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理 - app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount) - app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注 测试 - .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js, 使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
|
||
*
|
||
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
|
||
*
|
||
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
|
||
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
|
||
* 也避免引入未经验证的库影响既有交卷链路。
|
||
*
|
||
* 生成的包结构(最小合法 OOXML):
|
||
* [Content_Types].xml
|
||
* _rels/.rels
|
||
* word/document.xml
|
||
* word/styles.xml
|
||
* word/_rels/document.xml.rels
|
||
*
|
||
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
|
||
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
|
||
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
|
||
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
|
||
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
|
||
* 实体: < > & " ' '
|
||
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
|
||
*
|
||
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
|
||
*/
|
||
|
||
// ==================== XML / 实体 ====================
|
||
|
||
function decodeEntities(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/ /gi, ' ')
|
||
.replace(/</gi, '<')
|
||
.replace(/>/gi, '>')
|
||
.replace(/"/gi, '"')
|
||
.replace(/�*39;/g, "'")
|
||
.replace(/'/gi, "'")
|
||
.replace(/&#(\d+);/g, (m, d) => {
|
||
const code = Number(d);
|
||
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
||
})
|
||
// ⚠️ & 必须放最后:否则 `&lt;` 会被二次解码成 `<`
|
||
.replace(/&/gi, '&');
|
||
}
|
||
|
||
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
|
||
function esc(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ==================== 极简 HTML 解析(容错栈式) ====================
|
||
|
||
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
|
||
|
||
/**
|
||
* 词法正则。
|
||
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
|
||
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
|
||
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
|
||
* 具名组不受分支数量影响,从根上消除这类错位。
|
||
*/
|
||
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
|
||
|
||
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
||
|
||
function parseAttrs(raw) {
|
||
const attrs = {};
|
||
if (!raw) return attrs;
|
||
ATTR_RE.lastIndex = 0;
|
||
let m;
|
||
while ((m = ATTR_RE.exec(raw))) {
|
||
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
||
}
|
||
return attrs;
|
||
}
|
||
|
||
/**
|
||
* 把 HTML 片段解析为节点树。
|
||
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
|
||
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
|
||
*/
|
||
function parseHtml(html) {
|
||
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
|
||
const stack = [root];
|
||
const src = String(html == null ? '' : html);
|
||
TOKEN_RE.lastIndex = 0;
|
||
let m;
|
||
while ((m = TOKEN_RE.exec(src))) {
|
||
const g = m.groups || {};
|
||
if (g.text !== undefined) {
|
||
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
|
||
continue;
|
||
}
|
||
if (g.close !== undefined) {
|
||
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
|
||
const name = g.close.toLowerCase();
|
||
for (let i = stack.length - 1; i > 0; i--) {
|
||
if (stack[i].tag === name) {
|
||
stack.length = i;
|
||
break;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (g.open !== undefined) {
|
||
const tag = g.open.toLowerCase();
|
||
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
|
||
stack[stack.length - 1].children.push(node);
|
||
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
|
||
continue;
|
||
}
|
||
// 注释 / doctype / 处理指令:忽略
|
||
}
|
||
return root;
|
||
}
|
||
|
||
// ==================== 节点树 → 中间块序列 ====================
|
||
|
||
const BLOCK_TAGS = new Set([
|
||
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
|
||
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
|
||
]);
|
||
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
|
||
|
||
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
|
||
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
|
||
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||
|
||
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
|
||
const INLINE_ITALIC = new Set(['em', 'i']);
|
||
|
||
function styleOf(node) {
|
||
return (node && node.attrs && node.attrs.style) || '';
|
||
}
|
||
|
||
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
|
||
function alignOf(node) {
|
||
const s = styleOf(node);
|
||
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
|
||
return m ? m[1].toLowerCase() : '';
|
||
}
|
||
|
||
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
|
||
function firstLineTwips(node) {
|
||
const s = styleOf(node);
|
||
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
|
||
if (!m) return 0;
|
||
const pt = Number(m[1]);
|
||
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
|
||
}
|
||
|
||
/**
|
||
* 深度优先遍历,产出「块」序列:
|
||
* { type:'p', runs:[...], align, firstLine }
|
||
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
|
||
* run 形态:{ text, bold, italic, size, color } 或 { br: true }
|
||
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
|
||
*/
|
||
function walk(nodes, fmt, out) {
|
||
const flush = () => {
|
||
if (out.cur.length) {
|
||
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
|
||
out.cur = [];
|
||
out.curMeta = {};
|
||
}
|
||
};
|
||
|
||
for (const n of nodes) {
|
||
if (n.type === 'text') {
|
||
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
|
||
// 但 ` ` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
|
||
if (!/\S/.test(n.value) && !/ /i.test(n.value)) continue;
|
||
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
|
||
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
|
||
if (text === '') continue;
|
||
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
|
||
continue;
|
||
}
|
||
|
||
const tag = n.tag;
|
||
if (SKIP_TAGS.has(tag)) continue;
|
||
if (tag === 'br') {
|
||
out.cur.push({ br: true });
|
||
continue;
|
||
}
|
||
if (tag === 'table') {
|
||
flush();
|
||
out.blocks.push({ type: 'table', rows: extractRows(n) });
|
||
continue;
|
||
}
|
||
if (tag === 'hr') {
|
||
flush();
|
||
out.blocks.push({ type: 'p', runs: [] });
|
||
continue;
|
||
}
|
||
if (tag === 'li') {
|
||
flush();
|
||
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
|
||
out.cur.push({ text: '• ', bold: false, italic: false });
|
||
walk(n.children, fmt, out);
|
||
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
|
||
const last = out.blocks[out.blocks.length - 1];
|
||
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
|
||
continue;
|
||
}
|
||
if (BLOCK_TAGS.has(tag)) {
|
||
flush();
|
||
const nextFmt = HEADING_BOLD.has(tag)
|
||
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
|
||
: fmt;
|
||
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
|
||
walk(n.children, nextFmt, out);
|
||
flush();
|
||
continue;
|
||
}
|
||
// 行内标签
|
||
const nextFmt = Object.assign({}, fmt);
|
||
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
|
||
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
|
||
walk(n.children, nextFmt, out);
|
||
}
|
||
flush();
|
||
}
|
||
|
||
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
|
||
function extractRows(tableNode) {
|
||
const rows = [];
|
||
const collect = (nodes) => {
|
||
for (const n of nodes) {
|
||
if (n.type !== 'node') continue;
|
||
if (n.tag === 'tr') {
|
||
const cells = [];
|
||
for (const c of n.children) {
|
||
if (c.type !== 'node') continue;
|
||
if (c.tag !== 'td' && c.tag !== 'th') continue;
|
||
const cellOut = { blocks: [], cur: [], curMeta: {} };
|
||
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
|
||
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
|
||
}
|
||
rows.push({ cells });
|
||
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
|
||
collect(n.children);
|
||
}
|
||
}
|
||
};
|
||
collect(tableNode.children);
|
||
return rows;
|
||
}
|
||
|
||
/** HTML → 块序列(对外唯一入口) */
|
||
function htmlToBlocks(html) {
|
||
const out = { blocks: [], cur: [], curMeta: {} };
|
||
walk(parseHtml(html).children, { bold: false, italic: false }, out);
|
||
return out.blocks;
|
||
}
|
||
|
||
/** 纯文本 → 单块(自动按空行/换行切段) */
|
||
function textToBlocks(text) {
|
||
const s = String(text == null ? '' : text);
|
||
if (!s.trim()) return [];
|
||
return s
|
||
.split(/\r?\n/)
|
||
.map(line => line.trim())
|
||
.filter(line => line !== '')
|
||
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
|
||
}
|
||
|
||
// ==================== 块 → WordprocessingML ====================
|
||
|
||
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||
|
||
/**
|
||
* 规范化 run 的颜色值。
|
||
*
|
||
* ⛔ 只接受 6 位十六进制(可带 `#`),其余一律返回 '':
|
||
* OOXML 的 `w:val` 若被填入 `red` / `#F00` / 任意字符串,Word 会拒绝打开整个文档,
|
||
* 而调用方(导出服务)无法感知 —— 表现为「导出的 docx 打不开」,排查成本极高。
|
||
*
|
||
* @param {*} v
|
||
* @returns {string} 6 位大写十六进制或 ''
|
||
*/
|
||
function normalizeColor(v) {
|
||
if (v === null || v === undefined) return '';
|
||
const s = String(v).trim().replace(/^#/, '');
|
||
return /^[0-9a-fA-F]{6}$/.test(s) ? s.toUpperCase() : '';
|
||
}
|
||
|
||
function rPrOf(run) {
|
||
const parts = [];
|
||
if (run.bold) parts.push('<w:b/><w:bCs/>');
|
||
if (run.italic) parts.push('<w:i/><w:iCs/>');
|
||
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
|
||
// ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。
|
||
// 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。
|
||
const color = normalizeColor(run.color);
|
||
if (color) parts.push(`<w:color w:val="${color}"/>`);
|
||
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
|
||
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
|
||
return `<w:rPr>${parts.join('')}</w:rPr>`;
|
||
}
|
||
|
||
function renderRun(run) {
|
||
if (run.br) return '<w:r><w:br/></w:r>';
|
||
const text = String(run.text == null ? '' : run.text);
|
||
if (text === '') return '';
|
||
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
|
||
}
|
||
|
||
function pPrOf(block, extra) {
|
||
const parts = [];
|
||
const align = block.align || '';
|
||
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
|
||
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
|
||
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
|
||
if (extra) parts.push(extra);
|
||
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
|
||
}
|
||
|
||
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
|
||
function renderParagraph(block) {
|
||
const runs = (block.runs || []).map(renderRun).join('');
|
||
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
|
||
}
|
||
|
||
const TBL_BORDERS = '<w:tblBorders>'
|
||
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
|
||
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
|
||
.join('')
|
||
+ '</w:tblBorders>';
|
||
|
||
function renderTable(block) {
|
||
const rows = block.rows || [];
|
||
if (!rows.length) return '';
|
||
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
|
||
const trs = rows.map(r => {
|
||
const tcs = [];
|
||
for (let i = 0; i < cellCount; i++) {
|
||
const cell = r.cells[i];
|
||
let inner;
|
||
if (!cell || !cell.blocks.length) {
|
||
inner = '<w:p/>';
|
||
} else {
|
||
inner = cell.blocks.map(b => (b.type === 'table'
|
||
// 嵌套表扁平化为文本,避免非法嵌套
|
||
? renderParagraph({ runs: flattenTableText(b) })
|
||
: renderParagraph(b))).join('');
|
||
}
|
||
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
|
||
}
|
||
return `<w:tr>${tcs.join('')}</w:tr>`;
|
||
}).join('');
|
||
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
|
||
}
|
||
|
||
function flattenTableText(tableBlock) {
|
||
const buf = [];
|
||
for (const r of tableBlock.rows || []) {
|
||
const cells = [];
|
||
for (const c of r.cells || []) {
|
||
const texts = [];
|
||
for (const b of c.blocks || []) {
|
||
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
|
||
}
|
||
cells.push(texts.join(' ').trim());
|
||
}
|
||
buf.push({ br: false, text: cells.join(' | ') });
|
||
}
|
||
const runs = [];
|
||
buf.forEach((x, i) => {
|
||
if (i > 0) runs.push({ br: true });
|
||
runs.push({ text: x.text, bold: false, italic: false });
|
||
});
|
||
return runs;
|
||
}
|
||
|
||
function buildDocumentXml(blocks) {
|
||
const parts = [];
|
||
for (const b of blocks) {
|
||
if (b.type === 'table') {
|
||
const t = renderTable(b);
|
||
if (t) {
|
||
parts.push(t);
|
||
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
|
||
parts.push('<w:p/>');
|
||
}
|
||
} else {
|
||
parts.push(renderParagraph(b));
|
||
}
|
||
}
|
||
if (!parts.length) parts.push('<w:p/>');
|
||
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
|
||
+ `<w:body>${parts.join('')}</w:body></w:document>`;
|
||
}
|
||
|
||
// ==================== 打包为 .docx ====================
|
||
|
||
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
||
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
|
||
+ '</Types>';
|
||
|
||
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||
+ '</Relationships>';
|
||
|
||
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||
+ '</Relationships>';
|
||
|
||
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
+ `<w:styles xmlns:w="${W_NS}">`
|
||
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
|
||
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
|
||
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
|
||
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
|
||
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||
+ '</w:styles>';
|
||
|
||
/**
|
||
* 把若干 part 打成一个 ZIP Buffer。
|
||
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
|
||
*/
|
||
function zipParts(parts) {
|
||
return new Promise((resolve, reject) => {
|
||
let ZipArchive;
|
||
try {
|
||
({ ZipArchive } = require('archiver'));
|
||
} catch (e) {
|
||
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
|
||
return;
|
||
}
|
||
const chunks = [];
|
||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||
archive.on('error', reject);
|
||
archive.on('data', c => chunks.push(c));
|
||
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
||
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
|
||
archive.finalize();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成单个 .docx
|
||
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
|
||
* @returns {Promise<Buffer>}
|
||
*/
|
||
async function buildDocx(blocks) {
|
||
const list = ([]).concat(blocks || []).filter(Boolean);
|
||
const flat = [];
|
||
for (const b of list) {
|
||
if (Array.isArray(b)) flat.push(...b);
|
||
else flat.push(b);
|
||
}
|
||
return zipParts([
|
||
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
||
{ name: '_rels/.rels', data: ROOT_RELS },
|
||
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
|
||
{ name: 'word/styles.xml', data: STYLES_XML },
|
||
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
|
||
]);
|
||
}
|
||
|
||
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
|
||
async function zipDocxFiles(entries) {
|
||
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
|
||
}
|
||
|
||
module.exports = {
|
||
buildDocx,
|
||
zipDocxFiles,
|
||
htmlToBlocks,
|
||
textToBlocks,
|
||
decodeEntities,
|
||
esc,
|
||
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
|
||
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||
};
|