import { AlignmentType, BorderStyle, Document, LineRuleType, Packer, Paragraph, ShadingType, Table, TableCell, TableLayoutType, TableRow, TextRun, VerticalAlign, WidthType } from 'docx'; import { saveAs } from 'file-saver'; import JSZip from 'jszip'; import { TableUtils } from '@/common/js/TableUtils'; import { isMathFormulaTableRecord } from '@/utils/mathFormulaModule'; /** 斑马纹 rgb(250, 231, 232) */ const ODD_ROW_FILL = 'FAE7E8'; /** 标题 rgb(210, 90, 90) */ const TITLE_COLOR = 'D25A5A'; /** 引用 blue rgb(0, 130, 170) */ const BLUE_COLOR = '0082AA'; const FONT_NAME = 'Charis SIL'; /** 六号 = 7.5pt */ const TABLE_FONT_SIZE = 15; const TITLE_FONT_SIZE = TABLE_FONT_SIZE; /** Word「为字体调整字间距」:1 磅或更大(w:kern 以半磅为单位) */ const FONT_KERN_MIN_1PT = 2; /** Word「为字体调整字间距」:小二(18pt)或更大 */ const FONT_KERN_MIN_XIAO_ER = 36; function cmToTwips(cm) { return Math.round((cm * 1440) / 2.54); } /** 页面边距:上/下 2.54cm,左/右 1.91cm */ const PAGE_MARGINS = { top: cmToTwips(2.54), bottom: cmToTwips(2.54), left: cmToTwips(1.91), right: cmToTwips(1.91) }; /** 表格属性:宽 98.3%,整体居中,无环绕 */ const TABLE_WIDTH_PERCENT = 98.3; /** 表格选项(图二):单元格边距 上/下 0、左/右 0.19cm,自动适应内容 */ const TABLE_CELL_MARGIN_TWIPS = cmToTwips(0.19); const TABLE_CELL_MARGINS = { top: 0, bottom: 0, left: TABLE_CELL_MARGIN_TWIPS, right: TABLE_CELL_MARGIN_TWIPS }; /** 行属性:允许跨页断行,不指定行高 */ const TABLE_ROW_OPTIONS = { cantSplit: false }; /** 全篇段落:固定行距 10 磅(1pt = 20 twips → 10pt = 200) */ const WORD_PARAGRAPH_SPACING = { before: 0, after: 0, line: 200, lineRule: LineRuleType.EXACT, beforeAutoSpacing: false, afterAutoSpacing: false }; const WORD_DOCUMENT_STYLES = { default: { document: { paragraph: { spacing: WORD_PARAGRAPH_SPACING }, run: { font: FONT_NAME, size: TABLE_FONT_SIZE, kern: FONT_KERN_MIN_1PT } } }, paragraphStyles: [ { id: 'Normal', name: 'Normal', quickFormat: true, spacing: WORD_PARAGRAPH_SPACING, run: { font: FONT_NAME, size: TABLE_FONT_SIZE, kern: FONT_KERN_MIN_1PT } }, { id: 'Heading1', name: 'Heading 1', basedOn: 'Normal', next: 'Normal', quickFormat: true, spacing: WORD_PARAGRAPH_SPACING, run: { font: FONT_NAME, size: TITLE_FONT_SIZE, bold: true, color: TITLE_COLOR, kern: FONT_KERN_MIN_1PT } } ] }; /** 每个表格块末尾空行数 */ const TABLE_BLOCK_EMPTY_LINE_COUNT = 4; function createWordParagraph(children, options) { const opts = options || {}; let alignment = AlignmentType.JUSTIFIED; if (opts.alignment !== undefined && opts.alignment !== null) { alignment = opts.alignment; } const paragraphOptions = { spacing: WORD_PARAGRAPH_SPACING, indent: { left: 0, right: 0 }, children }; if (opts.title) { paragraphOptions.alignment = AlignmentType.CENTER; paragraphOptions.style = 'Normal'; } else { paragraphOptions.alignment = alignment; paragraphOptions.style = 'Normal'; } return new Paragraph(paragraphOptions); } function appendTableBlockEmptyLines(children) { let i = 0; for (i = 0; i < TABLE_BLOCK_EMPTY_LINE_COUNT; i += 1) { children.push(createTableParagraph([new TextRun('')], AlignmentType.JUSTIFIED)); } } /** 表格标题段落:居中,固定行距 10pt,段前段后 0,无缩进(非大纲一级) */ function createTitleParagraph(children) { return createWordParagraph(children, { alignment: AlignmentType.CENTER }); } function decodeTableCaptionXmlText(text) { return String(text || '') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); } function extractTableCaptionParagraphPlainText(pInner) { const parts = []; String(pInner || '').replace(/]*)?>([\s\S]*?)<\/w:t>/g, function (_match, value) { parts.push(decodeTableCaptionXmlText(value)); }); return parts.join('').replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); } function isBodyTableCaptionPlainText(text) { return /^Table\s+\d+/i.test(String(text || '').trim()); } function applyTableCaptionCenterParagraphPr(pInner) { if (//.test(pInner)) { let inner = pInner .replace(/]*\/>/g, '') .replace(/]*>[\s\S]*?<\/w:jc>/g, ''); return inner.replace(//, ''); } return '' + pInner; } /** 正文表格标题(Table N…)紧挨 w:tbl 前的段落强制居中,不含首页元数据表格 */ export function patchBodyTableCaptionCenterToXml(xml) { return xml.replace(/((?:]*>[\s\S]*?<\/w:p>\s*)+)(\s*]*>[\s\S]*?<\/w:p>/gi) || []; if (!blocks.length) { return match; } const firstText = extractTableCaptionParagraphPlainText( blocks[0].replace(/^]*>/, '').replace(/<\/w:p>$/, '') ); if (!isBodyTableCaptionPlainText(firstText)) { return match; } const centered = blocks.map(function (block) { return block.replace(/(]*>)([\s\S]*?)(<\/w:p>)/, function (_full, open, inner, close) { return open + applyTableCaptionCenterParagraphPr(inner) + close; }); }); return centered.join('') + tblOpen; }); } /** 导出后处理:除首页表格外,正文表格标题居中 */ async function patchBodyTableCaptionCenter(blob) { if (!blob) { return blob; } const zip = await JSZip.loadAsync(await blob.arrayBuffer()); const documentFile = zip.file('word/document.xml'); if (!documentFile) { return blob; } let xml = await documentFile.async('string'); if (!/\bTable\s+\d+/i.test(xml)) { return blob; } xml = patchBodyTableCaptionCenterToXml(xml); zip.file('word/document.xml', xml); return zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); } /** 图片标题:与表格标题相同字号/颜色/段落,但两端对齐;末尾无句点则补 . */ function ensureFigureTitleTrailingPeriod(text) { const trimmed = String(text || '').trim(); if (!trimmed) { return ''; } if (trimmed.endsWith('.')) { return trimmed; } return trimmed + '.'; } function createFigureTitleParagraph(children) { return createWordParagraph(children, { alignment: AlignmentType.JUSTIFIED }); } function appendFigureTitleParagraphs(children, titleHtml) { const segments = splitHtmlSegments(titleHtml) .map(function (segment) { return htmlToPlainText(segment).trim(); }) .filter(Boolean); if (!segments.length) { return; } segments.forEach(function (segment, index) { const text = index === segments.length - 1 ? ensureFigureTitleTrailingPeriod(segment) : segment; children.push( createFigureTitleParagraph([ new TextRun({ text: text, bold: true, color: TITLE_COLOR, font: FONT_NAME, size: TITLE_FONT_SIZE, kern: FONT_KERN_MIN_1PT }) ]) ); }); } function createTableParagraph(children, alignment) { return createWordParagraph(children, { alignment }); } function splitHtmlSegments(html) { const raw = String(html || ''); if (!raw) { return ['']; } const parts = raw.split(//gi); if (!parts.length) { return ['']; } return parts; } function decodeHtmlEntities(text) { return String(text || '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); } function htmlToPlainText(html) { const raw = String(html || ''); if (typeof document !== 'undefined') { const node = document.createElement('div'); node.innerHTML = raw; return (node.textContent || node.innerText || '').replace(/\u00a0/g, ' ').trim(); } return decodeHtmlEntities( raw .replace(//gi, '\n') .replace(/<[^>]*>/g, '') ).trim(); } function isBlueElement(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) { return false; } const tag = node.tagName.toLowerCase(); if (tag === 'blue') { return true; } if (tag === 'span' && node.classList && node.classList.contains('blue')) { return true; } return !!(node.classList && node.classList.contains('color-highlight')); } /** 参考文献标号 [1] / [1, 2]:逗号后补空格(与 HTML 排版规则一致,重复 3 次) */ function normalizeCitationBracketCommaSpacing(text) { return String(text || '').replace(/\[([^\]]*)\]/g, function (match, inner) { let fixed = inner; for (let i = 0; i < 3; i++) { fixed = fixed.replace(/(\d),(\d)/g, '$1, $2'); } return '[' + fixed + ']'; }); } /** 匹配正文参考文献标号:\[[0-9, -\-\]{1,}\] */ const CITATION_BRACKET_PATTERN = /\[[0-9, \-]+\]/g; function appendRunsForCitationText(text, style, appendRun) { const normalized = normalizeCitationBracketCommaSpacing(text); if (!normalized) { return; } let lastIndex = 0; let match; CITATION_BRACKET_PATTERN.lastIndex = 0; while ((match = CITATION_BRACKET_PATTERN.exec(normalized)) !== null) { if (match.index > lastIndex) { appendRun(normalized.slice(lastIndex, match.index), style); } appendRun(match[0], Object.assign({}, style, { blue: true })); lastIndex = match.index + match[0].length; } if (lastIndex < normalized.length) { appendRun(normalized.slice(lastIndex), style); } } function htmlToTextRuns(html, options) { const { bold = false } = options || {}; const runs = []; const raw = String(html || ''); if (!raw) { return [ new TextRun({ text: '', font: FONT_NAME, size: TABLE_FONT_SIZE, bold, kern: FONT_KERN_MIN_1PT }) ]; } if (typeof document === 'undefined') { const plainRuns = []; appendRunsForCitationText(htmlToPlainText(raw), { bold, italics: false, sup: false, sub: false, blue: false }, function ( text, style ) { plainRuns.push( new TextRun({ text, font: FONT_NAME, size: TABLE_FONT_SIZE, bold: bold || style.bold, italics: style.italic, superScript: style.sup, subScript: style.sub, color: style.blue ? BLUE_COLOR : undefined, kern: FONT_KERN_MIN_1PT }) ); }); if (plainRuns.length) { return plainRuns; } return [ new TextRun({ text: htmlToPlainText(raw), font: FONT_NAME, size: TABLE_FONT_SIZE, bold, kern: FONT_KERN_MIN_1PT }) ]; } const root = document.createElement('div'); root.innerHTML = raw; function appendRun(text, style) { if (!text) { return; } runs.push( new TextRun({ text, font: FONT_NAME, size: TABLE_FONT_SIZE, bold: bold || style.bold, italics: style.italic, superScript: style.sup, subScript: style.sub, color: style.blue ? BLUE_COLOR : undefined, kern: FONT_KERN_MIN_1PT }) ); } function walk(node, style) { if (node.nodeType === Node.TEXT_NODE) { appendRunsForCitationText( decodeHtmlEntities(node.textContent).replace(/\u00a0/g, ' '), style, appendRun ); return; } if (node.nodeType !== Node.ELEMENT_NODE) { return; } const tag = node.tagName.toLowerCase(); if (tag === 'br') { appendRun('\n', style); return; } const nextStyle = { bold: style.bold, italic: style.italic, sup: style.sup, sub: style.sub, blue: style.blue }; if (tag === 'b' || tag === 'strong') { nextStyle.bold = true; } if (tag === 'i' || tag === 'em') { nextStyle.italic = true; } if (tag === 'sup') { nextStyle.sup = true; } if (tag === 'sub') { nextStyle.sub = true; } if (isBlueElement(node)) { nextStyle.blue = true; } Array.from(node.childNodes).forEach((child) => walk(child, nextStyle)); } walk(root, { bold: false, italic: false, sup: false, sub: false, blue: false }); if (!runs.length) { return [ new TextRun({ text: htmlToPlainText(raw), font: FONT_NAME, size: TABLE_FONT_SIZE, bold, kern: FONT_KERN_MIN_1PT }) ]; } return runs; } /** 表头:相邻片段之间补空格,再规范为“一词一空” */ function formatHeaderWordSpacing(html) { const raw = String(html || ''); if (!raw) { return ''; } if (typeof document !== 'undefined') { const root = document.createElement('div'); root.innerHTML = raw; const parts = []; function collect(node) { if (node.nodeType === Node.TEXT_NODE) { const text = decodeHtmlEntities(node.textContent).replace(/\u00a0/g, ' ').trim(); if (text) { parts.push(text); } return; } if (node.nodeType !== Node.ELEMENT_NODE) { return; } if (node.tagName.toLowerCase() === 'br') { parts.push('\n'); return; } Array.from(node.childNodes).forEach(collect); } collect(root); return parts .join(' ') .replace(/\s+/g, ' ') .trim(); } return htmlToPlainText(raw) .split(/\s+/) .filter(Boolean) .join(' '); } function htmlToHeaderTextRuns(html) { return [ new TextRun({ text: formatHeaderWordSpacing(html), font: FONT_NAME, size: TABLE_FONT_SIZE, bold: true, kern: FONT_KERN_MIN_1PT }) ]; } /** 表格边框:黑色 0.5 磅(OOXML sz 单位为 1/8 pt → 0.5pt = 4) */ const TABLE_BORDER = { style: BorderStyle.SINGLE, size: 4, color: '000000' }; const TABLE_BORDER_NONE = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }; function createBorder(show) { return show ? TABLE_BORDER : TABLE_BORDER_NONE; } function createTableCell(cell, options) { const { header = false, odd = false, lastRow = false } = options; if (!cell || cell.rowspan === 0 || cell.colspan === 0) { return null; } const cellOptions = { verticalAlign: VerticalAlign.CENTER, borders: { top: createBorder(header), bottom: createBorder(header || lastRow), left: createBorder(false), right: createBorder(false) }, children: splitHtmlSegments(cell.text).map((segment) => createTableParagraph( header ? htmlToHeaderTextRuns(segment) : htmlToTextRuns(segment, { bold: false }), AlignmentType.LEFT ) ) }; if (cell.colspan > 1) { cellOptions.columnSpan = cell.colspan; } if (cell.rowspan > 1) { cellOptions.rowSpan = cell.rowspan; } if (odd) { cellOptions.shading = { fill: ODD_ROW_FILL, type: ShadingType.CLEAR, color: 'auto' }; } return new TableCell(cellOptions); } function createTableRow(cells) { return new TableRow({ ...TABLE_ROW_OPTIONS, children: cells }); } function buildTableRows(table) { const headers = (table && table.tableHeader) || []; const contents = (table && table.tableContent) || []; const oddRowIds = (table && table.oddRowIds) || []; const rows = []; headers.forEach((row) => { const cells = (row || []).map((cell) => createTableCell(cell, { header: true })).filter(Boolean); if (cells.length) { rows.push(createTableRow(cells)); } }); contents.forEach((row, rowIndex) => { const odd = oddRowIds.length ? oddRowIds.includes(row.rowId) : rowIndex % 2 === 0; const cells = (row || []) .map((cell) => createTableCell(cell, { odd, lastRow: rowIndex === contents.length - 1 }) ) .filter(Boolean); if (cells.length) { rows.push(createTableRow(cells)); } }); return rows; } function createWordDocument(children) { return new Document({ styles: WORD_DOCUMENT_STYLES, sections: [ { properties: { page: { margin: PAGE_MARGINS }, grid: { linePitch: 200 } }, children } ] }); } export function prepareTableExportItem(item) { if (!item || isMathFormulaTableRecord(item)) { return null; } if (item.table && item.table.tableHeader) { return { title: item.title || '', note: item.note || '', table: item.table }; } try { const tableList = typeof item.table === 'string' ? JSON.parse(item.table) : item.table; const splitResult = TableUtils.splitTable(tableList); const rowResult = TableUtils.addRowIdToData(splitResult.content); return { title: item.title || '', note: item.note || '', table: { tableHeader: splitResult.header, tableContent: rowResult.rowData, oddRowIds: rowResult.rowIds } }; } catch (e) { return null; } } function buildTableWordChildren(processedItem, options) { const opts = options || {}; const appendTrailingEmptyLines = opts.appendTrailingEmptyLines !== false; const title = (processedItem && processedItem.title) || ''; const note = (processedItem && processedItem.note) || ''; const table = (processedItem && processedItem.table) || {}; const children = []; if (title) { splitHtmlSegments(title).forEach((segment) => { children.push( createTitleParagraph([ new TextRun({ text: htmlToPlainText(segment), bold: true, color: TITLE_COLOR, font: FONT_NAME, size: TITLE_FONT_SIZE, kern: FONT_KERN_MIN_1PT }) ]) ); }); } const tableRows = buildTableRows(table); if (tableRows.length) { children.push( new Table({ width: { size: TABLE_WIDTH_PERCENT, type: WidthType.PERCENTAGE }, alignment: AlignmentType.CENTER, layout: TableLayoutType.AUTOFIT, margins: TABLE_CELL_MARGINS, borders: { top: TABLE_BORDER_NONE, bottom: TABLE_BORDER_NONE, left: TABLE_BORDER_NONE, right: TABLE_BORDER_NONE, insideHorizontal: TABLE_BORDER_NONE, insideVertical: TABLE_BORDER_NONE }, rows: tableRows }) ); } if (note) { splitHtmlSegments(note).forEach((segment) => { children.push( createTableParagraph(htmlToTextRuns(segment, { bold: false }), AlignmentType.JUSTIFIED) ); }); } if (appendTrailingEmptyLines) { appendTableBlockEmptyLines(children); } return children; } export function buildTableWordDocument(processedItem) { return createWordDocument(buildTableWordChildren(processedItem)); } export function buildAllTablesWordDocument(items) { const children = []; (items || []).forEach((item) => { const prepared = prepareTableExportItem(item); if (!prepared) { return; } const blockChildren = buildTableWordChildren(prepared); blockChildren.forEach((child) => { children.push(child); }); }); return createWordDocument(children); } function sanitizeFileName(name) { const base = String(name || 'table') .replace(/<[^>]+>/g, '') .replace(/[\\/:*?"<>|]/g, '') .trim() .slice(0, 80); return base || 'table'; } export async function downloadTableWord(processedItem, fileName) { const doc = buildTableWordDocument(processedItem); let blob = await Packer.toBlob(doc); blob = await patchBodyTableCaptionCenter(blob); saveAs(blob, `${sanitizeFileName(fileName || processedItem.title)}.docx`); } export async function downloadAllTablesWord(items, fileName) { const exportItems = []; (items || []).forEach((item) => { const prepared = prepareTableExportItem(item); if (prepared) { exportItems.push(prepared); } }); if (!exportItems.length) { const error = new Error('NO_TABLES'); throw error; } const doc = buildAllTablesWordDocument(exportItems); let blob = await Packer.toBlob(doc); blob = await patchBodyTableCaptionCenter(blob); saveAs(blob, `${sanitizeFileName(fileName || 'tables')}.docx`); } export { appendFigureTitleParagraphs, appendTableBlockEmptyLines, buildTableWordChildren, createFigureTitleParagraph, createTableParagraph, createWordDocument, ensureFigureTitleTrailingPeriod, FONT_KERN_MIN_1PT, FONT_KERN_MIN_XIAO_ER, FONT_NAME, htmlToPlainText, htmlToTextRuns, PAGE_MARGINS, patchBodyTableCaptionCenter, splitHtmlSegments, TABLE_FONT_SIZE, TITLE_COLOR, WORD_PARAGRAPH_SPACING };