| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071 |
- 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(/<br\s*\/?\s*>/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' && /^<br\s*\/?\s*>$/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,
- };
|