const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const { fileURLToPath } = require('node:url');
const { app, dialog, nativeImage } = require('electron');
const cheerio = require('cheerio');
const { imageSize } = require('image-size');
const { getGeneratedImagesDir, getImportedImagesDir } = require('../utils/paths.cjs');
const {
AlignmentType,
BorderStyle,
Document,
ExternalHyperlink,
HeadingLevel,
ImageRun,
LevelFormat,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableLayoutType,
TableRow,
TextRun,
UnderlineType,
WidthType,
} = require('docx');
const MAX_IMAGE_WIDTH = 520;
const NUMBERING_REFERENCE_PREFIX = 'technical-plan-numbering';
const DOCX_TABLE_WIDTH_TWIPS = 9000;
const MERMAID_EXPORT_RETRY_ATTEMPTS = 2;
const MERMAID_EXPORT_RETRY_DELAY_MS = 3000;
function encodeMermaidForInk(code) {
const state = JSON.stringify({
code: String(code || ''),
mermaid: { theme: 'default' },
});
return `pako:${zlib.deflateSync(Buffer.from(state, 'utf-8')).toString('base64url')}`;
}
function mermaidInkUrl(code) {
return `https://mermaid.ink/img/${encodeMermaidForInk(code)}?type=png&bgColor=!white`;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function clampPercent(value) {
return Math.max(0, Math.min(Math.round(Number(value) || 0), 100));
}
function reportProgress(context, progress, message, extra = {}) {
if (!context?.onProgress) return;
try {
context.onProgress({
phase: extra.phase || 'running',
progress: clampPercent(progress),
message,
warnings: [...(context.warnings || [])],
...extra,
});
} catch (error) {
console.warn('[export-word] progress callback failed', error);
}
}
function reportConversionProgress(context, message) {
const stats = context?.stats || {};
const total = Math.max(1, (stats.leafCount || 0) + (stats.mermaidCount || 0));
const done = Math.min(total, (context.convertedLeafCount || 0) + (context.convertedMermaidCount || 0));
reportProgress(context, 10 + (done / total) * 78, message);
}
function addWarning(context, message) {
if (context?.warnings) {
context.warnings.push(message);
}
console.warn(`[export-word] ${message}`);
}
function addUnsupportedHtmlWarning(context, tagName) {
const tag = String(tagName || '').toLowerCase();
if (!tag) return;
if (!context.unsupportedHtmlTags) {
context.unsupportedHtmlTags = new Set();
}
if (context.unsupportedHtmlTags.has(tag)) {
return;
}
context.unsupportedHtmlTags.add(tag);
addWarning(context, `HTML 标签 <${tag}> 导出时已降级,请核对 Word 内容。`);
}
function compactText(value, maxLength = 140) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
}
function countMermaidBlocks(content) {
return (String(content || '').match(/```mermaid[\s\S]*?```/gi) || []).length;
}
function countOutlineStats(items = []) {
let leafCount = 0;
let mermaidCount = 0;
for (const item of items || []) {
if (item.children?.length) {
const childStats = countOutlineStats(item.children);
leafCount += childStats.leafCount;
mermaidCount += childStats.mermaidCount;
} else {
leafCount += 1;
mermaidCount += countMermaidBlocks(item.content);
}
}
return { leafCount, mermaidCount };
}
function sanitizeFilename(value) {
return String(value || '标书文档')
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || '标书文档';
}
function cleanText(value) {
return String(value || '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
function textRun(text, options = {}) {
return new TextRun({
text: cleanText(text),
font: '宋体',
size: options.size || 24,
bold: options.bold,
italics: options.italics,
strike: options.strike,
color: options.color,
underline: options.underline ? { type: UnderlineType.SINGLE } : undefined,
});
}
function lineBreakRun() {
return new TextRun({ break: 1 });
}
function textRunsWithBreaks(value, options = {}) {
const parts = String(value || '').split(/
/gi);
const runs = [];
parts.forEach((part, index) => {
if (index > 0) {
runs.push(lineBreakRun());
}
if (part) {
runs.push(textRun(part, options));
}
});
return runs;
}
function paragraph(children, options = {}) {
return new Paragraph({
children: children?.length ? children : [textRun('')],
heading: options.heading,
alignment: options.alignment,
bullet: options.bullet,
numbering: options.numbering,
spacing: { before: options.before || 0, after: options.after ?? 160, line: 360 },
indent: options.indent,
border: options.border,
shading: options.shading,
});
}
function tableBorders() {
return {
top: { style: BorderStyle.SINGLE, size: 1, color: 'DCDFF6' },
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'DCDFF6' },
left: { style: BorderStyle.SINGLE, size: 1, color: 'DCDFF6' },
right: { style: BorderStyle.SINGLE, size: 1, color: 'DCDFF6' },
insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: 'E8EDF6' },
insideVertical: { style: BorderStyle.SINGLE, size: 1, color: 'E8EDF6' },
};
}
function tableColumnWidths(columnCount) {
const safeCount = Math.max(1, columnCount || 1);
const base = Math.floor(DOCX_TABLE_WIDTH_TWIPS / safeCount);
const widths = Array.from({ length: safeCount }, () => base);
widths[widths.length - 1] += DOCX_TABLE_WIDTH_TWIPS - (base * safeCount);
return widths;
}
function tableCellWidth(columnSpan, totalColumns) {
const safeTotal = Math.max(1, totalColumns || 1);
const safeSpan = Math.max(1, columnSpan || 1);
return Math.round((DOCX_TABLE_WIDTH_TWIPS * safeSpan) / safeTotal);
}
function createTableCell({ children, isHeader = false, columnSpan = 1, totalColumns = 1 }) {
const safeSpan = Math.max(1, columnSpan || 1);
return new TableCell({
children,
shading: isHeader ? { type: ShadingType.CLEAR, fill: 'F1F6FF' } : undefined,
margins: { top: 120, bottom: 120, left: 140, right: 140 },
columnSpan: safeSpan > 1 ? safeSpan : undefined,
width: { size: tableCellWidth(safeSpan, totalColumns), type: WidthType.DXA },
});
}
function createDocxTable(rows, columnCount) {
return new Table({
rows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: tableColumnWidths(columnCount),
layout: TableLayoutType.FIXED,
borders: tableBorders(),
});
}
function normalizeColumnSpan(value) {
const span = Number.parseInt(String(value || ''), 10);
return Number.isFinite(span) && span > 1 ? span : 1;
}
function isMarkdownTableRowLine(line) {
return /^\s*\|.*\|\s*$/.test(String(line || ''));
}
function isMarkdownTableDelimiterLine(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(String(line || ''));
}
function splitMarkdownTableCells(line) {
let source = String(line || '').trim();
if (!source.includes('|')) {
return [];
}
if (source.startsWith('|')) {
source = source.slice(1);
}
if (source.endsWith('|')) {
source = source.slice(0, -1);
}
const cells = [];
let current = '';
let escaped = false;
for (const char of source) {
if (char === '|' && !escaped) {
cells.push(current.trim());
current = '';
continue;
}
current += char;
escaped = char === '\\' && !escaped;
}
cells.push(current.trim());
return cells;
}
function isMarkdownTableDelimiterCell(cell) {
return /^:?-{3,}:?$/.test(String(cell || '').trim());
}
function markdownTableRowIndent(line) {
const match = /^(\s*)\|/.exec(String(line || ''));
return match ? match[1] : '';
}
function formatMarkdownTableRow(cells, indent = '') {
return `${indent}| ${cells.map((cell) => String(cell || '').trim()).join(' | ')} |`;
}
function expandCompressedMarkdownTableRows(headerLine, nextLine) {
if (!isMarkdownTableRowLine(headerLine) || !isMarkdownTableRowLine(nextLine)) {
return null;
}
const headerCells = splitMarkdownTableCells(headerLine);
const nextCells = splitMarkdownTableCells(nextLine);
const columnCount = headerCells.length;
if (columnCount < 2 || nextCells.length <= columnCount) {
return null;
}
const delimiterCells = nextCells.slice(0, columnCount);
if (!delimiterCells.every(isMarkdownTableDelimiterCell)) {
return null;
}
// 模型有时会把分隔行和后续数据行压成同一行,这里按表头列数拆回 GFM 表格。
const indent = markdownTableRowIndent(headerLine);
const lines = [formatMarkdownTableRow(headerCells, indent), formatMarkdownTableRow(delimiterCells, indent)];
const remainingCells = nextCells.slice(columnCount);
while (remainingCells.length) {
if (remainingCells.length > columnCount && !remainingCells[0] && remainingCells.length % columnCount !== 0) {
remainingCells.shift();
continue;
}
const rowCells = remainingCells.splice(0, columnCount);
if (rowCells.some((cell) => String(cell || '').trim())) {
lines.push(formatMarkdownTableRow(rowCells, indent));
}
}
return lines;
}
function expandInlineMarkdownTableRows(line) {
const source = String(line || '');
if (!/\|\s*:?-{3,}:?\s*\|/.test(source)) {
return [source];
}
const firstPipeIndex = source.indexOf('|');
if (firstPipeIndex < 0) {
return [source];
}
const prefix = source.slice(0, firstPipeIndex);
const isIndentedTableLine = /^\s*$/.test(prefix);
const tableText = source.slice(firstPipeIndex).trim();
const tableRows = tableText
.replace(/\|\s+\|/g, '|\n|')
.split('\n')
.map((row) => row.trim())
.filter(Boolean);
if (isIndentedTableLine) {
return tableRows.map((row) => `${prefix}${row}`);
}
return [prefix.trimEnd(), ...tableRows];
}
function normalizeMarkdownTablesForDocx(content) {
const expandedLines = String(content || '')
.replace(/\r\n?/g, '\n')
.split('\n')
.flatMap(expandInlineMarkdownTableRows);
const lines = [];
for (let index = 0; index < expandedLines.length; index += 1) {
const line = expandedLines[index];
const nextLine = expandedLines[index + 1] || '';
const compressedTableRows = expandCompressedMarkdownTableRows(line, nextLine);
const startsCompressedTable = Boolean(compressedTableRows);
const startsTable = isMarkdownTableRowLine(line) && isMarkdownTableDelimiterLine(nextLine);
const previousLine = lines[lines.length - 1] || '';
if ((startsTable || startsCompressedTable) && previousLine.trim() && !isMarkdownTableRowLine(previousLine)) {
lines.push('');
}
if (compressedTableRows) {
lines.push(...compressedTableRows);
index += 1;
continue;
}
lines.push(line);
}
return lines.join('\n');
}
function createOrderedListReference(context) {
if (!context.numberingReferences) {
context.numberingReferences = [];
}
context.numberingIndex = (context.numberingIndex || 0) + 1;
const reference = `${NUMBERING_REFERENCE_PREFIX}-${context.numberingIndex}`;
context.numberingReferences.push(reference);
return reference;
}
function headingLevel(level) {
if (level <= 1) return HeadingLevel.HEADING_1;
if (level === 2) return HeadingLevel.HEADING_2;
if (level === 3) return HeadingLevel.HEADING_3;
return HeadingLevel.HEADING_4;
}
function imageTypeFromMime(mime) {
if (!mime) return null;
if (mime.includes('png')) return 'png';
if (mime.includes('jpeg') || mime.includes('jpg')) return 'jpg';
if (mime.includes('gif')) return 'gif';
if (mime.includes('bmp')) return 'bmp';
if (mime.includes('webp')) return 'webp';
return null;
}
function imageTypeFromPath(filePath) {
const ext = path.extname(filePath || '').toLowerCase().replace('.', '');
if (ext === 'jpeg') return 'jpg';
return ['png', 'jpg', 'gif', 'bmp', 'webp'].includes(ext) ? ext : null;
}
function normalizeImageForDocx(loaded) {
if (!loaded?.buffer || !loaded.type) {
return loaded;
}
if (loaded.type !== 'webp') {
return loaded;
}
const image = nativeImage?.createFromBuffer ? nativeImage.createFromBuffer(loaded.buffer) : null;
if (!image || image.isEmpty()) {
throw new Error('WebP 图片转换失败');
}
return { buffer: image.toPNG(), type: 'png' };
}
function resolveAssetImagePath(url) {
if (!app?.getPath) return null;
const assetUrl = new URL(url);
const assetRoots = {
'generated-images': getGeneratedImagesDir(app),
'imported-images': getImportedImagesDir(app),
};
const rootDir = assetRoots[assetUrl.hostname];
if (!rootDir) return null;
const relativePath = decodeURIComponent(assetUrl.pathname.replace(/^\/+/, ''));
if (!relativePath) return null;
const baseDir = path.resolve(rootDir);
const resolvedPath = path.resolve(baseDir, relativePath);
if (resolvedPath !== baseDir && !resolvedPath.startsWith(`${baseDir}${path.sep}`)) {
return null;
}
return resolvedPath;
}
async function loadImage(source, context = {}) {
const url = String(source || '').trim();
if (!url) return null;
const dataUrlMatch = /^data:([^;,]+);base64,(.+)$/i.exec(url);
if (dataUrlMatch) {
return {
buffer: Buffer.from(dataUrlMatch[2], 'base64'),
type: imageTypeFromMime(dataUrlMatch[1]),
};
}
if (/^yibiao-asset:\/\//i.test(url)) {
const assetPath = resolveAssetImagePath(url);
if (!assetPath || !fs.existsSync(assetPath)) {
return null;
}
return {
buffer: fs.readFileSync(assetPath),
type: imageTypeFromPath(assetPath),
};
}
if (/^https?:\/\//i.test(url)) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`图片下载失败:${url}`);
}
const type = imageTypeFromMime(response.headers.get('content-type')) || imageTypeFromPath(new URL(url).pathname);
return { buffer: Buffer.from(await response.arrayBuffer()), type };
}
const fileUrlPrefix = 'file://';
const rawPath = url.startsWith(fileUrlPrefix) ? fileURLToPath(url) : url;
const resolvedPath = path.isAbsolute(rawPath)
? rawPath
: path.resolve(context.baseDir || process.cwd(), rawPath);
if (!fs.existsSync(resolvedPath)) {
return null;
}
return {
buffer: fs.readFileSync(resolvedPath),
type: imageTypeFromPath(resolvedPath),
};
}
async function loadImageWithRetry(source, context = {}, options = {}) {
const retryAttempts = Math.max(0, Number(options.retryAttempts) || 0);
const retryDelayMs = Math.max(0, Number(options.retryDelayMs) || 0);
let attempt = 0;
while (attempt <= retryAttempts) {
try {
return await loadImage(source, context);
} catch (error) {
if (attempt >= retryAttempts) {
throw error;
}
attempt += 1;
if (typeof options.onRetry === 'function') {
options.onRetry(attempt, error);
}
if (retryDelayMs > 0) {
await delay(retryDelayMs);
}
}
}
return null;
}
async function imageRunFromNode(node, context, options = {}) {
let loaded = null;
const imageLabel = compactText(node.alt || node.url || '未知图片');
try {
loaded = await loadImageWithRetry(node.url, context, options.loadRetry);
} catch (error) {
const message = `图片无法导出:${imageLabel},${compactText(error.message || '下载失败', 120)}`;
addWarning(context, message);
return textRun(`[${message}]`, { color: 'C83220' });
}
if (!loaded?.buffer || !loaded.type) {
const message = `图片无法导出:${imageLabel},未找到可用图片数据`;
addWarning(context, message);
return textRun(`[${message}]`, { color: 'C83220' });
}
try {
loaded = normalizeImageForDocx(loaded);
} catch (error) {
const message = `图片无法导出:${imageLabel},${error.message || '图片格式转换失败'}`;
addWarning(context, message);
return textRun(`[${message}]`, { color: 'C83220' });
}
let size;
try {
size = imageSize(loaded.buffer);
} catch (error) {
const message = `图片无法导出:${imageLabel},图片尺寸识别失败`;
addWarning(context, message);
return textRun(`[${message}]`, { color: 'C83220' });
}
const sourceWidth = size.width || MAX_IMAGE_WIDTH;
const sourceHeight = size.height || Math.round(MAX_IMAGE_WIDTH * 0.62);
const ratio = Math.min(1, MAX_IMAGE_WIDTH / sourceWidth);
const width = Math.round(sourceWidth * ratio);
const height = Math.round(sourceHeight * ratio);
return new ImageRun({
type: loaded.type,
data: loaded.buffer,
transformation: { width, height },
altText: {
title: cleanText(node.alt || '图片'),
description: cleanText(node.alt || node.url || 'Markdown 图片'),
name: cleanText(node.alt || 'image'),
},
});
}
async function imageParagraphFromSource(source, alt, context, options = {}) {
return paragraph([await imageRunFromNode({ url: source, alt }, context, options)], { alignment: AlignmentType.CENTER });
}
async function inlineRuns(nodes = [], context = {}, marks = {}) {
const runs = [];
for (const node of nodes) {
if (node.type === 'text') {
runs.push(...textRunsWithBreaks(node.value, marks));
} else if (node.type === 'strong') {
runs.push(...await inlineRuns(node.children, context, { ...marks, bold: true }));
} else if (node.type === 'emphasis') {
runs.push(...await inlineRuns(node.children, context, { ...marks, italics: true }));
} else if (node.type === 'delete') {
runs.push(...await inlineRuns(node.children, context, { ...marks, strike: true }));
} else if (node.type === 'inlineCode') {
runs.push(new TextRun({ text: cleanText(node.value), font: 'Consolas', size: 22, color: '155BD7' }));
} else if (node.type === 'break') {
runs.push(lineBreakRun());
} else if (node.type === 'html' && /^
$/i.test(String(node.value || '').trim())) {
runs.push(lineBreakRun());
} else if (node.type === 'html') {
const $ = cheerio.load(String(node.value || ''), null, false);
runs.push(...await htmlInlineRuns($, $.root().contents().toArray(), context, marks));
} else if (node.type === 'link') {
const children = await inlineRuns(node.children, context, { ...marks, color: '2174FD', underline: true });
runs.push(new ExternalHyperlink({ link: node.url, children }));
} else if (node.type === 'image') {
runs.push(await imageRunFromNode(node, context));
} else if (node.children) {
runs.push(...await inlineRuns(node.children, context, marks));
}
}
return runs;
}
function nodeText(node) {
if (!node) return '';
if (node.type === 'text' || node.type === 'inlineCode') return String(node.value || '');
return (node.children || []).map(nodeText).join('');
}
function isImageOnlyParagraph(node) {
return (node.children || []).filter((child) => child.type !== 'text' || String(child.value || '').trim()).length === 1
&& (node.children || []).some((child) => child.type === 'image');
}
function isFigureCaptionParagraph(node) {
return /^图[::]/.test(nodeText(node).trim());
}
function htmlTagName(node) {
return String(node?.name || '').toLowerCase();
}
function hasBlockHtmlChildren($, node) {
return $(node).contents().toArray().some((child) => ['table', 'ul', 'ol', 'blockquote', 'pre', 'div', 'section', 'article', 'img'].includes(htmlTagName(child)));
}
async function htmlInlineRuns($, nodes = [], context = {}, marks = {}) {
const runs = [];
for (const node of nodes) {
if (node.type === 'text') {
runs.push(...textRunsWithBreaks(node.data || '', marks));
continue;
}
if (node.type !== 'tag') {
continue;
}
const tag = htmlTagName(node);
if (tag === 'br') {
runs.push(lineBreakRun());
} else if (tag === 'strong' || tag === 'b') {
runs.push(...await htmlInlineRuns($, $(node).contents().toArray(), context, { ...marks, bold: true }));
} else if (tag === 'em' || tag === 'i') {
runs.push(...await htmlInlineRuns($, $(node).contents().toArray(), context, { ...marks, italics: true }));
} else if (tag === 'code') {
runs.push(new TextRun({ text: cleanText($(node).text()), font: 'Consolas', size: 22, color: '155BD7' }));
} else if (tag === 'a') {
const href = $(node).attr('href') || '';
const children = await htmlInlineRuns($, $(node).contents().toArray(), context, { ...marks, color: '2174FD', underline: true });
if (href) {
runs.push(new ExternalHyperlink({ link: href, children }));
} else {
runs.push(...children);
}
} else if (tag === 'img') {
runs.push(await imageRunFromNode({ url: $(node).attr('src'), alt: $(node).attr('alt') || 'HTML 图片' }, context));
} else {
if (!['span', 'small', 'sub', 'sup'].includes(tag)) {
addUnsupportedHtmlWarning(context, tag);
}
runs.push(...await htmlInlineRuns($, $(node).contents().toArray(), context, marks));
}
}
return runs;
}
async function htmlTableToDocx($, tableNode, context) {
const rows = [];
const rowDescriptors = $(tableNode).find('tr').toArray().map((rowNode) => {
const cells = $(rowNode).children('th,td').toArray().map((cellNode) => ({
node: cellNode,
columnSpan: normalizeColumnSpan($(cellNode).attr('colspan')),
}));
return {
cells,
columnCount: cells.reduce((sum, cell) => sum + cell.columnSpan, 0),
};
}).filter((row) => row.cells.length);
const maxColumns = Math.max(1, ...rowDescriptors.map((row) => row.columnCount));
for (const row of rowDescriptors) {
const cells = [];
for (const [cellIndex, cell] of row.cells.entries()) {
const cellNode = cell.node;
const isHeader = htmlTagName(cellNode) === 'th';
const remainingSpan = cellIndex === row.cells.length - 1 ? maxColumns - row.columnCount : 0;
cells.push(createTableCell({
children: [paragraph(await htmlInlineRuns($, $(cellNode).contents().toArray(), context, { bold: isHeader }), { after: 80 })],
isHeader,
columnSpan: cell.columnSpan + Math.max(0, remainingSpan),
totalColumns: maxColumns,
}));
}
rows.push(new TableRow({ children: cells }));
}
if (!rows.length) {
return [];
}
return [createDocxTable(rows, maxColumns)];
}
async function htmlListToDocx($, listNode, context, options = {}) {
const blocks = [];
const ordered = htmlTagName(listNode) === 'ol';
const numberingReference = ordered ? createOrderedListReference(context) : null;
for (const itemNode of $(listNode).children('li').toArray()) {
const inlineNodes = $(itemNode).contents().toArray().filter((child) => !['ul', 'ol'].includes(htmlTagName(child)));
const listOptions = ordered
? { numbering: { reference: numberingReference, level: Math.min(options.listLevel || 0, 2) } }
: { bullet: { level: Math.min(options.listLevel || 0, 2) } };
blocks.push(paragraph(await htmlInlineRuns($, inlineNodes, context), listOptions));
for (const childList of $(itemNode).children('ul,ol').toArray()) {
blocks.push(...await htmlListToDocx($, childList, context, { ...options, listLevel: (options.listLevel || 0) + 1 }));
}
}
return blocks;
}
async function htmlNodeToDocxBlocks($, node, context, options = {}) {
if (node.type === 'text') {
const text = String(node.data || '').trim();
return text ? [paragraph([textRun(text)])] : [];
}
if (node.type !== 'tag') {
return [];
}
const tag = htmlTagName(node);
if (tag === 'table') {
return htmlTableToDocx($, node, context);
}
if (tag === 'img') {
return [await imageParagraphFromSource($(node).attr('src'), $(node).attr('alt') || 'HTML 图片', context)];
}
if (tag === 'ul' || tag === 'ol') {
return htmlListToDocx($, node, context, options);
}
if (tag === 'blockquote') {
return [paragraph(await htmlInlineRuns($, $(node).contents().toArray(), context, { color: '536176' }), {
indent: { left: 360 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: '2174FD' } },
shading: { type: ShadingType.CLEAR, fill: 'F6F9FF' },
})];
}
if (tag === 'pre') {
return [paragraph([new TextRun({ text: cleanText($(node).text()), font: 'Consolas', size: 21, color: '243048' })], {
shading: { type: ShadingType.CLEAR, fill: 'F6F9FF' },
indent: { left: 260, right: 260 },
})];
}
if (tag === 'br') {
return [paragraph([lineBreakRun()])];
}
if (['div', 'section', 'article'].includes(tag) && hasBlockHtmlChildren($, node)) {
return htmlNodesToDocxBlocks($, $(node).contents().toArray(), context, options);
}
if (tag === 'p' && hasBlockHtmlChildren($, node)) {
return htmlNodesToDocxBlocks($, $(node).contents().toArray(), context, options);
}
if (['p', 'div', 'section', 'article', 'span', 'strong', 'b', 'em', 'i', 'a', 'code'].includes(tag)) {
return [paragraph(await htmlInlineRuns($, $(node).contents().toArray(), context), {
alignment: /^图[::]/.test($(node).text().trim()) ? AlignmentType.CENTER : undefined,
})];
}
addUnsupportedHtmlWarning(context, tag);
return htmlNodesToDocxBlocks($, $(node).contents().toArray(), context, options);
}
async function htmlNodesToDocxBlocks($, nodes = [], context = {}, options = {}) {
const blocks = [];
for (const node of nodes) {
blocks.push(...await htmlNodeToDocxBlocks($, node, context, options));
}
return blocks;
}
async function htmlToDocxBlocks(html, context = {}, options = {}) {
const source = String(html || '').trim();
if (!source) {
return [];
}
const $ = cheerio.load(source, null, false);
const blocks = await htmlNodesToDocxBlocks($, $.root().contents().toArray(), context, options);
if (!blocks.length) {
addWarning(context, '部分 HTML 内容未能导出,请核对 Word 内容。');
}
return blocks;
}
async function tableCellParagraphs(cell, context, isHeader = false) {
const phrasingNodes = (cell.children || []).filter((child) => child.type !== 'paragraph');
if (phrasingNodes.length) {
return [paragraph(await inlineRuns(phrasingNodes, context, { bold: isHeader }), { after: 80 })];
}
const blocks = await markdownNodesToDocx(cell.children || [], context, { inTable: true });
if (!blocks.length) return [paragraph([textRun('')], { after: 80 })];
return blocks.filter((block) => block instanceof Paragraph);
}
async function markdownNodesToDocx(nodes = [], context = {}, options = {}) {
const blocks = [];
for (const node of nodes) {
if (node.type === 'heading') {
blocks.push(paragraph(await inlineRuns(node.children, context), {
heading: headingLevel(node.depth),
before: node.depth === 1 ? 280 : 180,
after: 120,
}));
} else if (node.type === 'paragraph') {
blocks.push(paragraph(await inlineRuns(node.children, context), {
after: options.inTable ? 80 : 160,
alignment: !options.inTable && (isImageOnlyParagraph(node) || isFigureCaptionParagraph(node)) ? AlignmentType.CENTER : undefined,
}));
} else if (node.type === 'list') {
const numberingReference = node.ordered ? createOrderedListReference(context) : null;
for (const item of node.children || []) {
const firstParagraph = (item.children || []).find((child) => child.type === 'paragraph');
const restChildren = (item.children || []).filter((child) => child !== firstParagraph);
const listOptions = node.ordered
? { numbering: { reference: numberingReference, level: Math.min(options.listLevel || 0, 2) } }
: { bullet: { level: Math.min(options.listLevel || 0, 2) } };
blocks.push(paragraph(await inlineRuns(firstParagraph?.children || [], context), listOptions));
blocks.push(...await markdownNodesToDocx(restChildren, context, { ...options, listLevel: (options.listLevel || 0) + 1 }));
}
} else if (node.type === 'table') {
const rows = [];
const maxColumns = Math.max(1, ...(node.children || []).map((row) => row.children?.length || 0));
for (const [rowIndex, row] of (node.children || []).entries()) {
const cells = [];
const rowCells = row.children || [];
for (const [cellIndex, cell] of rowCells.entries()) {
const columnSpan = cellIndex === rowCells.length - 1
? Math.max(1, maxColumns - rowCells.length + 1)
: 1;
cells.push(createTableCell({
children: await tableCellParagraphs(cell, context, rowIndex === 0),
isHeader: rowIndex === 0,
columnSpan,
totalColumns: maxColumns,
}));
}
rows.push(new TableRow({ children: cells }));
}
if (rows.length) {
blocks.push(createDocxTable(rows, maxColumns));
}
} else if (node.type === 'blockquote') {
for (const child of node.children || []) {
if (child.type === 'paragraph') {
blocks.push(paragraph(await inlineRuns(child.children, context, { color: '536176' }), {
indent: { left: 360 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: '2174FD' } },
shading: { type: ShadingType.CLEAR, fill: 'F6F9FF' },
}));
} else {
blocks.push(...await markdownNodesToDocx([child], context, options));
}
}
} else if (node.type === 'code') {
if (String(node.lang || '').toLowerCase() === 'mermaid') {
const nextIndex = (context.convertedMermaidCount || 0) + 1;
const total = context.stats?.mermaidCount || nextIndex;
reportConversionProgress(context, `正在转换 Mermaid 图 ${nextIndex}/${total},可能需要联网等待。`);
blocks.push(await imageParagraphFromSource(mermaidInkUrl(node.value), 'Mermaid 图', context, {
loadRetry: {
retryAttempts: MERMAID_EXPORT_RETRY_ATTEMPTS,
retryDelayMs: MERMAID_EXPORT_RETRY_DELAY_MS,
onRetry: (attempt) => {
reportConversionProgress(context, `Mermaid 图 ${nextIndex}/${total} 转换失败,3 秒后第 ${attempt} 次重试。`);
},
},
}));
context.convertedMermaidCount = nextIndex;
reportConversionProgress(context, `Mermaid 图 ${nextIndex}/${total} 已处理。`);
} else {
blocks.push(paragraph([new TextRun({ text: cleanText(node.value), font: 'Consolas', size: 21, color: '243048' })], {
shading: { type: ShadingType.CLEAR, fill: 'F6F9FF' },
indent: { left: 260, right: 260 },
}));
}
} else if (node.type === 'html') {
blocks.push(...await htmlToDocxBlocks(node.value, context, options));
} else if (node.type === 'thematicBreak') {
blocks.push(paragraph([textRun('────────────────────────', { color: 'DCDFF6' })], { alignment: AlignmentType.CENTER }));
} else if (node.children) {
blocks.push(...await markdownNodesToDocx(node.children, context, options));
}
}
return blocks;
}
async function parseMarkdown(content) {
const [{ unified }, remarkParse, remarkGfm] = await Promise.all([
import('unified'),
import('remark-parse'),
import('remark-gfm'),
]);
return unified().use(remarkParse.default).use(remarkGfm.default).parse(normalizeMarkdownTablesForDocx(content));
}
async function markdownToDocxBlocks(content, context = {}) {
const tree = await parseMarkdown(content);
return markdownNodesToDocx(tree.children || [], context);
}
async function addMarkdownContent(children, content, context) {
children.push(...await markdownToDocxBlocks(content, context));
}
async function addOutlineItems(children, items, context, level = 1) {
for (const item of items || []) {
const title = `${item.id || ''} ${item.title || '未命名章节'}`.trim();
children.push(paragraph([textRun(title, { bold: true })], {
heading: headingLevel(level),
before: level === 1 ? 320 : 200,
after: 120,
}));
if (!item.children?.length) {
if (String(item.content || '').trim()) {
await addMarkdownContent(children, item.content, context);
}
context.convertedLeafCount = (context.convertedLeafCount || 0) + 1;
reportConversionProgress(context, `已处理 ${context.convertedLeafCount}/${context.stats?.leafCount || context.convertedLeafCount} 个正文小节。`);
continue;
}
await addOutlineItems(children, item.children, context, level + 1);
}
}
function createNumberingConfig(context) {
const references = context.numberingReferences || [];
if (!references.length) {
return undefined;
}
return {
config: references.map((reference) => ({
reference,
levels: [0, 1, 2].map((level) => ({
level,
format: LevelFormat.DECIMAL,
text: `%${level + 1}.`,
alignment: AlignmentType.START,
style: {
paragraph: {
indent: { left: 720 + level * 420, hanging: 260 },
},
},
})),
})),
};
}
async function buildDocxResult(payload, options = {}) {
const stats = countOutlineStats(payload.outline || []);
const context = {
baseDir: payload.base_dir || payload.baseDir,
onProgress: options.onProgress,
warnings: options.warnings || [],
stats,
convertedLeafCount: 0,
convertedMermaidCount: 0,
numberingReferences: [],
numberingIndex: 0,
unsupportedHtmlTags: new Set(),
};
const children = [
paragraph([textRun('内容由 AI 生成', { italics: true, size: 18 })], { alignment: AlignmentType.CENTER, after: 120 }),
paragraph([textRun(payload.project_name || '投标技术文件', { bold: true, size: 34 })], { alignment: AlignmentType.CENTER, after: 300 }),
];
reportProgress(context, 10, stats.mermaidCount
? `准备导出正文,并转换 ${stats.mermaidCount} 张 Mermaid 图。`
: '准备导出正文。');
await addOutlineItems(children, payload.outline || [], context);
reportProgress(context, 90, '正在生成 Word 文件。');
const numbering = createNumberingConfig(context);
const doc = new Document({
...(numbering ? { numbering } : {}),
styles: {
default: {
document: {
run: { font: '宋体', size: 24 },
paragraph: { spacing: { line: 360, after: 160 } },
},
},
},
sections: [{
properties: {
page: {
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
},
},
children,
}],
});
return { buffer: await Packer.toBuffer(doc), warnings: context.warnings, stats };
}
async function buildDocxBuffer(payload, options = {}) {
const result = await buildDocxResult(payload, options);
return result.buffer;
}
function createExportService() {
return {
async exportWord(payload = {}, onProgress) {
if (!Array.isArray(payload.outline) || !payload.outline.length) {
throw new Error('没有可导出的目录内容');
}
const stats = countOutlineStats(payload.outline || []);
const progressContext = { onProgress, warnings: [], stats };
reportProgress(progressContext, 2, stats.mermaidCount
? `检测到 ${stats.mermaidCount} 张 Mermaid 图,导出时会转换为 Word 图片。`
: '正在准备 Word 导出。');
const defaultFilename = `${sanitizeFilename(payload.project_name || '标书文档')}.docx`;
const defaultDir = app?.getPath ? app.getPath('documents') : process.env.USERPROFILE || process.cwd();
const result = await dialog.showSaveDialog({
title: '导出 Word 文档',
defaultPath: path.join(defaultDir, defaultFilename),
filters: [{ name: 'Word 文档', extensions: ['docx'] }],
});
if (result.canceled || !result.filePath) {
reportProgress(progressContext, 0, '已取消导出。', { phase: 'canceled' });
return { success: false, canceled: true, message: '已取消导出' };
}
const warnings = [];
const buildResult = await buildDocxResult(payload, { onProgress, warnings });
reportProgress({ onProgress, warnings: buildResult.warnings, stats: buildResult.stats }, 96, '正在写入 Word 文件。');
fs.writeFileSync(result.filePath, buildResult.buffer);
const message = buildResult.warnings.length
? `Word 已导出,但有 ${buildResult.warnings.length} 处图片未能插入,请打开文档核对。`
: 'Word 已导出,请打开文档核对图片、表格和版式。';
reportProgress({ onProgress, warnings: buildResult.warnings, stats: buildResult.stats }, 100, message, { phase: 'success' });
return { success: true, path: result.filePath, message, warnings: buildResult.warnings };
},
};
}
module.exports = {
buildDocxBuffer,
buildDocxResult,
createExportService,
};