'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, '"'); } // ==================== 极简 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*(?[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?[a-zA-Z][a-zA-Z0-9:-]*)(?(?:"[^"]*"|'[^']*'|[^>"'])*?)(?\/?)>|(?[^<]+)/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; // 原始换行/制表压成单空格:真实换行只由
与段落边界产生 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(''); if (run.italic) parts.push(''); parts.push(''); // ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。 // 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。 const color = normalizeColor(run.color); if (color) parts.push(``); const sz = Number(run.size) > 0 ? Number(run.size) : 21; parts.push(``); return `${parts.join('')}`; } function renderRun(run) { if (run.br) return ''; const text = String(run.text == null ? '' : run.text); if (text === '') return ''; return `${rPrOf(run)}${esc(text)}`; } function pPrOf(block, extra) { const parts = []; const align = block.align || ''; if (align) parts.push(``); const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0; if (fl) parts.push(``); if (extra) parts.push(extra); return parts.length ? `${parts.join('')}` : ''; } /** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */ function renderParagraph(block) { const runs = (block.runs || []).map(renderRun).join(''); return `${pPrOf(block)}${runs}`; } const TBL_BORDERS = '' + ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] .map(side => ``) .join('') + ''; 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 = ''; } else { inner = cell.blocks.map(b => (b.type === 'table' // 嵌套表扁平化为文本,避免非法嵌套 ? renderParagraph({ runs: flattenTableText(b) }) : renderParagraph(b))).join(''); } tcs.push(`${inner}`); } return `${tcs.join('')}`; }).join(''); return `${TBL_BORDERS}${trs}`; } 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(''); } } else { parts.push(renderParagraph(b)); } } if (!parts.length) parts.push(''); parts.push(''); return '' + `` + `${parts.join('')}`; } // ==================== 打包为 .docx ==================== const CONTENT_TYPES = '' + '' + '' + '' + '' + '' + ''; const ROOT_RELS = '' + '' + '' + ''; const DOC_RELS = '' + '' + '' + ''; const STYLES_XML = '' + `` + '' + '' + '' + '' + '' + ''; /** * 把若干 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} */ 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 }, };