/** * 原始DOCX XML导出层 * 只替换已有段落中的文字并重新打包 */ 'use strict'; const JSZip = require('jszip'); const cheerio = require('cheerio'); const imageSize = require('image-size'); const XML_PATH = 'word/document.xml'; const RELS_PATH = 'word/_rels/document.xml.rels'; const CONTENT_TYPES_PATH = '[Content_Types].xml'; const escapeXml = value => String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const getBlockText = block => String(block?.plainText || '') .replace(/\r/g, '') .replace(/\n+/g, ' ') .trim(); const getStructureInfo = block => { if (block?.structureInfo && typeof block.structureInfo === 'object') return block.structureInfo; if (typeof block?.structureInfo === 'string') { try { const parsed = JSON.parse(block.structureInfo); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (error) { return {}; } } return {}; }; const normalizeXmlText = value => String(value || '') .replace(/(?:<\/w:tab>)?/g, ' ') .replace(/(?:<\/w:br>)?/g, '\n') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/\s+/g, '') .trim(); const getParagraphs = documentXml => { const paragraphs = []; documentXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { paragraphs.push({ xml: paragraphXml, text: normalizeXmlText(paragraphXml) }); return paragraphXml; }); return paragraphs; }; const stripHtml = value => String(value || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); const getTableText = tableXml => normalizeXmlText(tableXml); const getHtmlTableCells = html => { const $ = cheerio.load(String(html || ''), { decodeEntities: false }); return $('tr').toArray().flatMap(row => $(row).find('th,td').toArray().map(cell => { const cellHtml = $(cell).html() || ''; return { html: cellHtml, text: stripHtml(cellHtml) }; })); }; const getOriginalTableCells = block => (block?.structureInfo?.rows || []) .flatMap(row => row || []) .map(cell => String(cell?.text || '').trim()); const getXmlTableCells = tableXml => { const cells = []; tableXml.replace(/]*)?>[\s\S]*?<\/w:tc>/g, cellXml => { cells.push(cellXml); return cellXml; }); return cells; }; const getImageDataFromBlock = block => { const html = String(block?.htmlContent || ''); const match = html.match(/data:image\/(png|jpeg|jpg|gif|bmp);base64,([A-Za-z0-9+/=\s]+)/i); if (!match) return null; const extension = match[1].toLowerCase() === 'jpg' ? 'jpeg' : match[1].toLowerCase(); return { extension, mimeType: `image/${extension}`, buffer: Buffer.from(match[2].replace(/\s+/g, ''), 'base64'), }; }; const getNextRelationshipId = relationships => { const ids = [...relationships.matchAll(/Id="rId(\d+)"/g)].map(match => Number(match[1])); return `rId${Math.max(0, ...ids) + 1}`; }; const getNextMediaIndex = zip => { const indexes = Object.keys(zip.files) .map(name => name.match(/^word\/media\/image(\d+)\.[^/]+$/i)) .filter(Boolean) .map(match => Number(match[1])); return Math.max(0, ...indexes) + 1; }; const buildImageDrawingXml = ({ relationshipId, width, height }) => { const cx = Math.max(1, Math.round(width * 9525)); const cy = Math.max(1, Math.round(height * 9525)); return ``; }; const appendImageRelationship = (relationships, relationshipId, target) => { const relation = ``; if (relationships.includes('')) { return relationships.replace('', `${relation}`); } return `${relation}`; }; const registerImageContentType = (contentTypes, extension, mimeType) => { if (new RegExp(`]+Extension="${extension}"`, 'i').test(contentTypes)) return contentTypes; const node = ``; return contentTypes.replace('', `${node}`); }; const buildBlockXmlMapping = async ({ sourceBuffer, blocks }) => { const zip = await JSZip.loadAsync(sourceBuffer); const documentFile = zip.file(XML_PATH); if (!documentFile) throw new Error('DOCX 缺少 word/document.xml'); const documentXml = await documentFile.async('string'); const paragraphs = getParagraphs(documentXml); const tables = []; documentXml.replace(/]*)?>[\s\S]*?<\/w:tbl>/g, tableXml => { tables.push(tableXml); return tableXml; }); let cursor = 0; let tableCursor = 0; return blocks.map(block => { if (String(block?.blockType || '').toLowerCase() === 'table') { const sourceText = normalizeXmlText(getBlockText(block)); const tableIndex = tables.findIndex((tableXml, index) => index >= tableCursor && getTableText(tableXml).includes(sourceText)); if (tableIndex >= 0) { tableCursor = tableIndex + 1; return { ...block, structureInfo: { ...(block.structureInfo || {}), xmlTableIndex: tableIndex }, }; } } const sourceText = normalizeXmlText(getBlockText(block)); if (!sourceText) return block; let foundIndex = -1; for (let index = cursor; index < paragraphs.length; index += 1) { const xmlText = paragraphs[index].text; if (xmlText && (xmlText === sourceText || xmlText.includes(sourceText) || sourceText.includes(xmlText))) { foundIndex = index; break; } } if (foundIndex < 0) return block; cursor = foundIndex + 1; return { ...block, structureInfo: { ...(block.structureInfo || {}), xmlParagraphIndex: foundIndex, }, }; }); }; /** * 编辑/删除后 blockIndex 可能已经重排,不能用它推断原 DOCX 段落位置。 * 用编辑前保存的原文校验旧映射,失效时按原文顺序重新定位,避免导出静默回退为原文件。 */ const repairParagraphMappings = (documentXml, blocks, deletedParagraphIndexes = []) => { const paragraphs = getParagraphs(documentXml); const deleted = new Set(deletedParagraphIndexes.map(Number)); let cursor = 0; return blocks.map(block => { const originalStructureInfo = getStructureInfo(block); if (originalStructureInfo.xmlTableIndex !== undefined) { return { ...block, structureInfo: originalStructureInfo }; } const structureInfo = { ...originalStructureInfo }; const anchorText = normalizeXmlText( structureInfo.originalPlainText || block?.plainText || '' ); if (!anchorText) return block; const currentIndex = Number(structureInfo.xmlParagraphIndex); // 原始 DOCX 没有增删段落时,段落索引是稳定的。即使当前文字已经被编辑, // 也必须信任首次解析保存的位置,不能再按相似文字搜索,否则重复段落会错配。 if (Number.isInteger(currentIndex) && currentIndex >= 0 && currentIndex < paragraphs.length && !deleted.has(currentIndex)) { cursor = Math.max(cursor, currentIndex + 1); return block; } const foundIndex = paragraphs.findIndex((paragraph, index) => index >= cursor && !deleted.has(index) && ( paragraph.text === anchorText || paragraph.text.includes(anchorText) || anchorText.includes(paragraph.text) )); if (foundIndex < 0) return block; cursor = foundIndex + 1; return { ...block, structureInfo: { ...structureInfo, xmlParagraphIndex: foundIndex }, }; }); }; const getRunText = runXml => normalizeXmlText(runXml); const normalizeColor = value => { const color = String(value || '').trim().replace(/^#/, ''); if (/^[0-9a-f]{6}$/i.test(color)) return color.toUpperCase(); if (/^[0-9a-f]{3}$/i.test(color)) return color.split('').map(char => char + char).join('').toUpperCase(); return ''; }; const parseInlineStyle = styleText => { const style = {}; String(styleText || '').split(';').forEach(item => { const separator = item.indexOf(':'); if (separator < 0) return; const name = item.slice(0, separator).trim().toLowerCase(); const value = item.slice(separator + 1).trim(); if (name) style[name] = value; }); return style; }; const buildRunProperties = marks => { const properties = []; if (marks.bold) properties.push(''); if (marks.italic) properties.push(''); if (marks.underline) properties.push(''); if (marks.strike) properties.push(''); if (marks.color) properties.push(``); if (marks.fontFamily) { const font = escapeXml(marks.fontFamily.replace(/["']/g, '').split(',')[0].trim()); if (font) properties.push(``); } if (marks.fontSize) { const match = String(marks.fontSize).match(/([0-9]+(?:\.[0-9]+)?)\s*(px|pt)?/i); if (match) { const points = match[2]?.toLowerCase() === 'px' ? Number(match[1]) * 0.75 : Number(match[1]); if (Number.isFinite(points) && points > 0) { const halfPoints = Math.round(points * 2); properties.push(``); } } } return properties.length ? `${properties.join('')}` : ''; }; const collectHtmlRuns = html => { const $ = cheerio.load(String(html || ''), { decodeEntities: false }, false); const runs = []; const visit = (node, inheritedMarks = {}) => { if (node.type === 'text') { if (node.data) runs.push({ text: node.data, marks: inheritedMarks }); return; } if (node.type !== 'tag' && node.type !== 'root') return; const tag = String(node.name || '').toLowerCase(); const attributes = node.attribs || {}; const style = parseInlineStyle(attributes.style); const marks = { ...inheritedMarks, bold: inheritedMarks.bold || tag === 'strong' || tag === 'b' || /bold|[5-9]00/i.test(style['font-weight'] || ''), italic: inheritedMarks.italic || tag === 'em' || tag === 'i' || /italic|oblique/i.test(style['font-style'] || ''), underline: inheritedMarks.underline || tag === 'u' || /underline/i.test(style['text-decoration'] || ''), strike: inheritedMarks.strike || tag === 's' || tag === 'del' || /line-through/i.test(style['text-decoration'] || ''), color: normalizeColor(style.color || attributes.color) || inheritedMarks.color || '', fontFamily: style['font-family'] || inheritedMarks.fontFamily || '', fontSize: style['font-size'] || inheritedMarks.fontSize || '', }; if (tag === 'br') { runs.push({ text: '\n', marks: inheritedMarks }); return; } (node.children || []).forEach(child => visit(child, marks)); }; const root = $.root().get(0); (root?.children || []).forEach(node => visit(node)); return runs; }; const hasTiptapInlineFormatting = html => /<(strong|b|em|i|u|s|del|span|br)\b/i.test(String(html || '')); const replaceParagraphWithHtmlRuns = (paragraphXml, html) => { const runs = collectHtmlRuns(html).filter(run => run.text.length > 0); if (!runs.length) return paragraphXml; const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; const runXml = runs.map(run => { const textParts = String(run.text).replace(/\r\n?/g, '\n').split('\n'); const content = textParts.map((part, index) => { const line = part ? `${escapeXml(part)}` : ''; return index === 0 ? line : `${line}`; }).join(''); return `${buildRunProperties(run.marks)}${content}`; }).join(''); return `${paragraphProperties}${runXml}`; }; const hasBlockContentChanges = block => { const structureInfo = getStructureInfo(block); if (structureInfo.edited) return true; const currentText = normalizeXmlText(block?.plainText || ''); const originalText = structureInfo.originalPlainText === undefined ? null : normalizeXmlText(structureInfo.originalPlainText); const currentHtml = String(block?.htmlContent || '').trim(); const originalHtml = structureInfo.originalHtmlContent === undefined ? null : String(structureInfo.originalHtmlContent || '').trim(); return (originalText !== null && originalText !== currentText) || (originalHtml !== null && originalHtml !== currentHtml); }; const replaceParagraphWithPlainText = (paragraphXml, text) => { const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; const firstRunProperties = (paragraphXml.match(/]*)?>\s*()/) || ['', ''])[1]; const visibleText = escapeXml(String(text || '')); return `${paragraphProperties}${firstRunProperties}${visibleText}`; }; /** * 按原 run 的文字长度分配新文本 * 无新增 Tiptap mark 时保留原 run 属性,避免普通编辑丢失原文格式 */ const replaceTextNodes = (paragraphXml, text) => { const runs = []; paragraphXml.replace(/]*)?>[\s\S]*?<\/w:r>/g, runXml => { runs.push({ runXml, length: getRunText(runXml).length }); return runXml; }); if (!runs.length) return paragraphXml; const totalOriginalLength = runs.reduce((sum, run) => sum + run.length, 0); const nextText = String(text || ''); let offset = 0; const chunks = runs.map((run, index) => { if (index === runs.length - 1) return nextText.slice(offset); const share = totalOriginalLength > 0 ? Math.round(nextText.length * run.length / totalOriginalLength) : (index === 0 ? nextText.length : 0); const chunk = nextText.slice(offset, offset + share); offset += share; return chunk; }); let runCursor = 0; return paragraphXml.replace(/]*)?>[\s\S]*?<\/w:r>/g, runXml => { const index = runCursor++; const chunk = chunks[index] || ''; const withoutText = runXml.replace(/]*)?>[\s\S]*?<\/w:t>/g, ''); if (!chunk) return withoutText; const textNode = `${escapeXml(chunk)}`; const runEnd = withoutText.lastIndexOf(''); return `${withoutText.slice(0, runEnd)}${textNode}${withoutText.slice(runEnd)}`; }); }; const replaceParagraphText = (paragraphXml, block) => { if (!hasBlockContentChanges(block)) return paragraphXml; if (hasTiptapInlineFormatting(block?.htmlContent)) { return replaceParagraphWithHtmlRuns(paragraphXml, block.htmlContent); } const text = getBlockText(block); if (!text) return paragraphXml; return replaceParagraphWithPlainText(paragraphXml, text); }; const replaceTableText = (tableXml, block) => { const cellTexts = getHtmlTableCells(block?.htmlContent); if (!cellTexts.length) return tableXml; const originalCells = getOriginalTableCells(block); const xmlCells = getXmlTableCells(tableXml); const replacements = new Map(); let searchStart = 0; cellTexts.forEach((cell, htmlIndex) => { const currentText = String(cell.text || '').trim(); const originalText = String(originalCells[htmlIndex] || '').trim(); // 未变化的单元格不重建,保留原有 run、换行和单元格内部结构 if (!currentText || currentText === originalText) return; let targetIndex = -1; if (!originalCells.length) { // 兼容早期任务没有保存 structureInfo.rows 的数据 targetIndex = htmlIndex < xmlCells.length ? htmlIndex : -1; } else if (originalText) { targetIndex = xmlCells.findIndex((xmlCell, index) => index >= searchStart && normalizeXmlText(xmlCell) === normalizeXmlText(originalText)); } else if (htmlIndex < xmlCells.length && !normalizeXmlText(xmlCells[htmlIndex])) { // 空单元格没有文字锚点,只允许写入同位置的 XML 空单元格 targetIndex = htmlIndex; } if (targetIndex >= 0) { replacements.set(targetIndex, cell); searchStart = targetIndex + 1; } }); if (!replacements.size) return tableXml; let xmlCellCursor = 0; return tableXml.replace(/]*)?>[\s\S]*?<\/w:tc>/g, cellXml => { const targetIndex = xmlCellCursor++; const cell = replacements.get(targetIndex); if (!cell) return cellXml; const cellText = cell.text; const paragraphs = cellXml.match(/]*)?>[\s\S]*?<\/w:p>/g) || []; if (!paragraphs.length) return cellXml; const lines = String(cellText).split(/\r?\n/); let paragraphCursor = 0; return cellXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { const line = lines[paragraphCursor] === undefined ? '' : lines[paragraphCursor]; paragraphCursor += 1; if (hasTiptapInlineFormatting(cell.html)) { return replaceParagraphWithHtmlRuns(paragraphXml, cell.html); } return replaceTextNodes(paragraphXml, line); }); }); }; const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], imageWriter = null, stats = {}) => { const editableBlocks = blocks .filter(block => ['paragraph', 'heading', 'table'].includes(String(block?.blockType || '').toLowerCase())) .sort((a, b) => Number(a.blockIndex || 0) - Number(b.blockIndex || 0)); const deleted = new Set(deletedParagraphIndexes.map(Number)); const tableBlocks = editableBlocks.filter(block => block?.structureInfo?.xmlTableIndex !== undefined); let tableIndex = 0; let nextXml = documentXml.replace(/]*)?>[\s\S]*?<\/w:tbl>/g, tableXml => { const block = tableBlocks.find(item => Number(item.structureInfo.xmlTableIndex) === tableIndex); tableIndex += 1; if (!block) return tableXml; const replacedTable = replaceTableText(tableXml, block); if (replacedTable !== tableXml) stats.tableRewrites = (stats.tableRewrites || 0) + 1; return replacedTable; }); let paragraphIndex = 0; return nextXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { const currentIndex = paragraphIndex; paragraphIndex += 1; if (deleted.has(currentIndex)) { stats.deletedParagraphs = (stats.deletedParagraphs || 0) + 1; return ''; } const block = editableBlocks.find(item => Number(item?.structureInfo?.xmlParagraphIndex) === currentIndex); if (!block) return paragraphXml; if (imageWriter) { const image = getImageDataFromBlock(block); if (image) return imageWriter(paragraphXml, image); } if (!hasBlockContentChanges(block)) return paragraphXml; const replacedParagraph = replaceParagraphText(paragraphXml, block); if (replacedParagraph !== paragraphXml) stats.paragraphRewrites = (stats.paragraphRewrites || 0) + 1; return replacedParagraph; }); }; /** * 使用原始 DOCX 作为模板回写 block 文本 * 映射按解析块顺序进行,确保不编辑时原文件可以原样重新打包 */ const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIndexes = [], logger = null }) => { const zip = await JSZip.loadAsync(sourceBuffer); const documentFile = zip.file(XML_PATH); if (!documentFile) throw new Error('DOCX 缺少 word/document.xml'); const documentXml = await documentFile.async('string'); const mappedBlocks = repairParagraphMappings(documentXml, blocks, deletedParagraphIndexes) .map(block => ({ ...block, structureInfo: getStructureInfo(block) })); const unmappedEditedBlocks = mappedBlocks.filter(block => { const blockType = String(block?.blockType || '').toLowerCase(); if (!['paragraph', 'heading', 'table'].includes(blockType)) return false; const structureInfo = getStructureInfo(block); const hasContentChange = structureInfo.edited || ( structureInfo.originalPlainText !== undefined && normalizeXmlText(structureInfo.originalPlainText) !== normalizeXmlText(block?.plainText || '') ); if (!hasContentChange) return false; return blockType === 'table' ? structureInfo.xmlTableIndex === undefined : structureInfo.xmlParagraphIndex === undefined; }); if (unmappedEditedBlocks.length) { const indexes = unmappedEditedBlocks.map(block => block.blockIndex).join(', '); throw new Error(`导出失败:已修改段落未找到原文档位置(blockIndex: ${indexes}),已阻止导出原文件`); } const hasXmlMapping = mappedBlocks.some(block => block?.structureInfo?.xmlParagraphIndex !== undefined || block?.structureInfo?.xmlTableIndex !== undefined ); // 历史任务没有 XML 映射,原样返回可避免错误按顺序改写导致正文丢失。 if (!hasXmlMapping && !deletedParagraphIndexes.length) return sourceBuffer; const imageBlocks = mappedBlocks.filter(block => getImageDataFromBlock(block)); let relationships = zip.file(RELS_PATH) ? await zip.file(RELS_PATH).async('string') : ''; let contentTypes = zip.file(CONTENT_TYPES_PATH) ? await zip.file(CONTENT_TYPES_PATH).async('string') : ''; let mediaIndex = getNextMediaIndex(zip); const imageWriter = imageBlocks.length ? (paragraphXml, image) => { const relationshipId = getNextRelationshipId(relationships); const mediaName = `image${mediaIndex}.${image.extension}`; mediaIndex += 1; zip.file(`word/media/${mediaName}`, image.buffer); relationships = appendImageRelationship(relationships, relationshipId, `media/${mediaName}`); contentTypes = registerImageContentType(contentTypes, image.extension, image.mimeType); let width = 600; let height = 400; try { const dimensions = imageSize.imageSize(image.buffer); width = Math.min(600, dimensions.width || width); height = Math.max(1, width * (dimensions.height || height) / (dimensions.width || width)); } catch (error) { // 图片尺寸损坏时使用稳定兜底尺寸,避免导出整体失败 } const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; return `${paragraphProperties}${buildImageDrawingXml({ relationshipId, width, height })}`; } : null; const rewriteStats = {}; const nextXml = updateDocumentXml(documentXml, mappedBlocks, deletedParagraphIndexes, imageWriter, rewriteStats); logger?.info?.(`[plagiarism] export rewriteStats ${JSON.stringify(rewriteStats)}`); const changedBlocks = mappedBlocks.filter(block => { const structureInfo = getStructureInfo(block); return structureInfo.edited || ( structureInfo.originalPlainText !== undefined && normalizeXmlText(structureInfo.originalPlainText) !== normalizeXmlText(block?.plainText || '') ); }); if (changedBlocks.length && !rewriteStats.paragraphRewrites && !rewriteStats.tableRewrites && !imageBlocks.length) { throw new Error(`导出失败:检测到 ${changedBlocks.length} 个已修改 block,但 DOCX 没有实际回写`); } const hasVisibleText = /]*)?>[^<]+<\/w:t>/.test(nextXml); if (!hasVisibleText && !imageBlocks.length) return sourceBuffer; zip.file(XML_PATH, nextXml); if (imageBlocks.length) { zip.file(RELS_PATH, relationships); zip.file(CONTENT_TYPES_PATH, contentTypes); } return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); }; module.exports = { buildBlockXmlMapping, exportDocxFromOriginal, };