Files
tougao_web/src/utils/exportTableWord.js
2026-06-24 16:52:24 +08:00

849 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
function extractTableCaptionParagraphPlainText(pInner) {
const parts = [];
String(pInner || '').replace(/<w:t(?:\s[^>]*)?>([\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 (/<w:pPr>/.test(pInner)) {
let inner = pInner
.replace(/<w:jc\b[^>]*\/>/g, '')
.replace(/<w:jc\b[^>]*>[\s\S]*?<\/w:jc>/g, '');
return inner.replace(/<w:pPr>/, '<w:pPr><w:jc w:val="center"/>');
}
return '<w:pPr><w:jc w:val="center"/></w:pPr>' + pInner;
}
/** 正文表格标题Table N…紧挨 w:tbl 前的段落强制居中,不含首页元数据表格 */
export function patchBodyTableCaptionCenterToXml(xml) {
return xml.replace(/((?:<w:p\b[^>]*>[\s\S]*?<\/w:p>\s*)+)(\s*<w:tbl\b)/gi, function (match, paragraphGroup, tblOpen) {
const blocks = paragraphGroup.match(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi) || [];
if (!blocks.length) {
return match;
}
const firstText = extractTableCaptionParagraphPlainText(
blocks[0].replace(/^<w:p\b[^>]*>/, '').replace(/<\/w:p>$/, '')
);
if (!isBodyTableCaptionPlainText(firstText)) {
return match;
}
const centered = blocks.map(function (block) {
return block.replace(/(<w:p\b[^>]*>)([\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(/<br\s*\/?>/gi);
if (!parts.length) {
return [''];
}
return parts;
}
function decodeHtmlEntities(text) {
return String(text || '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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(/<br\s*\/?>/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
};