20 changed files with 1278 additions and 1082 deletions
@ -0,0 +1,488 @@ |
|||||
|
/** |
||||
|
* 原始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, '"') |
||||
|
.replace(/'/g, '''); |
||||
|
|
||||
|
const getBlockText = block => String(block?.plainText || '') |
||||
|
.replace(/\r/g, '') |
||||
|
.replace(/\n+/g, ' ') |
||||
|
.trim(); |
||||
|
|
||||
|
const normalizeXmlText = value => String(value || '') |
||||
|
.replace(/<w:tab\s*\/?>(?:<\/w:tab>)?/g, ' ') |
||||
|
.replace(/<w:br\s*\/?>(?:<\/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(/<w:p(?:\s[^>]*)?>[\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(/<w:tc(?:\s[^>]*)?>[\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 `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Mermaid diagram"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="diagram.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${relationshipId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`; |
||||
|
}; |
||||
|
|
||||
|
const appendImageRelationship = (relationships, relationshipId, target) => { |
||||
|
const relation = `<Relationship Id="${relationshipId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${target}"/>`; |
||||
|
if (relationships.includes('</Relationships>')) { |
||||
|
return relationships.replace('</Relationships>', `${relation}</Relationships>`); |
||||
|
} |
||||
|
return `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relation}</Relationships>`; |
||||
|
}; |
||||
|
|
||||
|
const registerImageContentType = (contentTypes, extension, mimeType) => { |
||||
|
if (new RegExp(`<Default[^>]+Extension="${extension}"`, 'i').test(contentTypes)) return contentTypes; |
||||
|
const node = `<Default Extension="${extension}" ContentType="${mimeType}"/>`; |
||||
|
return contentTypes.replace('</Types>', `${node}</Types>`); |
||||
|
}; |
||||
|
|
||||
|
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(/<w:tbl(?:\s[^>]*)?>[\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) => { |
||||
|
const paragraphs = getParagraphs(documentXml); |
||||
|
let cursor = 0; |
||||
|
return blocks.map(block => { |
||||
|
if (block?.structureInfo?.xmlTableIndex !== undefined) return block; |
||||
|
const structureInfo = block?.structureInfo && typeof block.structureInfo === 'object' |
||||
|
? { ...block.structureInfo } |
||||
|
: {}; |
||||
|
const anchorText = normalizeXmlText( |
||||
|
structureInfo.originalPlainText || block?.plainText || '' |
||||
|
); |
||||
|
if (!anchorText) return block; |
||||
|
|
||||
|
const currentIndex = Number(structureInfo.xmlParagraphIndex); |
||||
|
const currentParagraph = Number.isInteger(currentIndex) ? paragraphs[currentIndex] : null; |
||||
|
const currentMatches = currentParagraph?.text && ( |
||||
|
currentParagraph.text === anchorText || |
||||
|
currentParagraph.text.includes(anchorText) || |
||||
|
anchorText.includes(currentParagraph.text) |
||||
|
); |
||||
|
if (currentMatches) { |
||||
|
cursor = Math.max(cursor, currentIndex + 1); |
||||
|
return block; |
||||
|
} |
||||
|
|
||||
|
const foundIndex = paragraphs.findIndex((paragraph, index) => index >= cursor && ( |
||||
|
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('<w:b/>'); |
||||
|
if (marks.italic) properties.push('<w:i/>'); |
||||
|
if (marks.underline) properties.push('<w:u w:val="single"/>'); |
||||
|
if (marks.strike) properties.push('<w:strike/>'); |
||||
|
if (marks.color) properties.push(`<w:color w:val="${marks.color}"/>`); |
||||
|
if (marks.fontFamily) { |
||||
|
const font = escapeXml(marks.fontFamily.replace(/["']/g, '').split(',')[0].trim()); |
||||
|
if (font) properties.push(`<w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="${font}"/>`); |
||||
|
} |
||||
|
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(`<w:sz w:val="${halfPoints}"/><w:szCs w:val="${halfPoints}"/>`); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return properties.length ? `<w:rPr>${properties.join('')}</w:rPr>` : ''; |
||||
|
}; |
||||
|
|
||||
|
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(/<w:pPr(?:\s[^>]*)?>[\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 ? `<w:t xml:space="preserve">${escapeXml(part)}</w:t>` : ''; |
||||
|
return index === 0 ? line : `<w:br/>${line}`; |
||||
|
}).join(''); |
||||
|
return `<w:r>${buildRunProperties(run.marks)}${content}</w:r>`; |
||||
|
}).join(''); |
||||
|
return `<w:p>${paragraphProperties}${runXml}</w:p>`; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 按原 run 的文字长度分配新文本 |
||||
|
* 无新增 Tiptap mark 时保留原 run 属性,避免普通编辑丢失原文格式 |
||||
|
*/ |
||||
|
const replaceTextNodes = (paragraphXml, text) => { |
||||
|
const runs = []; |
||||
|
paragraphXml.replace(/<w:r(?:\s[^>]*)?>[\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(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/g, runXml => { |
||||
|
const index = runCursor++; |
||||
|
const chunk = chunks[index] || ''; |
||||
|
const withoutText = runXml.replace(/<w:t(?:\s[^>]*)?>[\s\S]*?<\/w:t>/g, ''); |
||||
|
if (!chunk) return withoutText; |
||||
|
const textNode = `<w:t xml:space="preserve">${escapeXml(chunk)}</w:t>`; |
||||
|
const runEnd = withoutText.lastIndexOf('</w:r>'); |
||||
|
return `${withoutText.slice(0, runEnd)}${textNode}${withoutText.slice(runEnd)}`; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const replaceParagraphText = (paragraphXml, block) => { |
||||
|
if (hasTiptapInlineFormatting(block?.htmlContent)) { |
||||
|
return replaceParagraphWithHtmlRuns(paragraphXml, block.htmlContent); |
||||
|
} |
||||
|
const text = getBlockText(block); |
||||
|
if (!text) return paragraphXml; |
||||
|
return replaceTextNodes(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(/<w:tc(?:\s[^>]*)?>[\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(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g) || []; |
||||
|
if (!paragraphs.length) return cellXml; |
||||
|
const lines = String(cellText).split(/\r?\n/); |
||||
|
let paragraphCursor = 0; |
||||
|
return cellXml.replace(/<w:p(?:\s[^>]*)?>[\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) => { |
||||
|
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(/<w:tbl(?:\s[^>]*)?>[\s\S]*?<\/w:tbl>/g, tableXml => { |
||||
|
const block = tableBlocks.find(item => Number(item.structureInfo.xmlTableIndex) === tableIndex); |
||||
|
tableIndex += 1; |
||||
|
return block ? replaceTableText(tableXml, block) : tableXml; |
||||
|
}); |
||||
|
let paragraphIndex = 0; |
||||
|
return nextXml.replace(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { |
||||
|
const currentIndex = paragraphIndex; |
||||
|
paragraphIndex += 1; |
||||
|
if (deleted.has(currentIndex)) 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); |
||||
|
} |
||||
|
return replaceParagraphText(paragraphXml, block); |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 使用原始 DOCX 作为模板回写 block 文本 |
||||
|
* 映射按解析块顺序进行,确保不编辑时原文件可以原样重新打包 |
||||
|
*/ |
||||
|
const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIndexes = [] }) => { |
||||
|
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); |
||||
|
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') |
||||
|
: '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>'; |
||||
|
let contentTypes = zip.file(CONTENT_TYPES_PATH) |
||||
|
? await zip.file(CONTENT_TYPES_PATH).async('string') |
||||
|
: '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>'; |
||||
|
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(/<w:pPr(?:\s[^>]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; |
||||
|
return `<w:p>${paragraphProperties}${buildImageDrawingXml({ relationshipId, width, height })}</w:p>`; |
||||
|
} : null; |
||||
|
const nextXml = updateDocumentXml(documentXml, mappedBlocks, deletedParagraphIndexes, imageWriter); |
||||
|
const hasVisibleText = /<w:t(?:\s[^>]*)?>[^<]+<\/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, |
||||
|
}; |
||||
Loading…
Reference in new issue