import JSZip from 'jszip' const resultTablePattern = /]*)?>[\s\S]*?<\/w:tbl>/g const floatingPositionPattern = /]*\/>/ const tableIndentPattern = /]*\/>/ const runFontsPattern = /]*\/>/g function alignReportHeader(xml, reportCode) { // 旧检验报告把页码放在浮动文本框中;SuperDoc 重新分页时会丢失 Word 的横向锚点。 // 保留报告编号的所有 Word runs(模板填充值可能被拆到下一 run),只重建页码段落。 if (!xml.includes('报告编号') || !/\bPAGE\b/.test(xml) || !/\bNUMPAGES\b/.test(xml)) return null const wordNamespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' const document = new DOMParser().parseFromString(xml, 'application/xml') const header = document.documentElement if (header.localName !== 'hdr' || document.getElementsByTagName('parsererror').length) return null const children = node => Array.from(node.childNodes).filter(child => child.nodeType === 1) const paragraphs = children(header).filter(node => node.localName === 'p') const reportParagraph = paragraphs.find(paragraph => children(paragraph).some(run => run.localName === 'r' && run.textContent.includes('报告编号'))) if (!reportParagraph) return null const reportRuns = children(reportParagraph).filter(node => node.localName === 'r') const labelIndex = reportRuns.findIndex(run => run.textContent.includes('报告编号')) if (labelIndex < 0) return null const reportText = reportRuns.slice(labelIndex).map(run => run.textContent).join('') const missingCode = !reportText.replace(/^.*?报告编号\s*[::]\s*/, '').trim() && reportCode?.trim() const alreadyAligned = paragraphs.length === 2 && paragraphs[0] === reportParagraph && !xml.includes('w:txbxContent') && paragraphs[1].textContent.includes('第') if (alreadyAligned && !missingCode) return null const element = name => document.createElementNS(wordNamespace, `w:${name}`) const appendRun = (paragraph, name, value, preserveSpace = false) => { const run = element('r') const text = element(name) if (preserveSpace) text.setAttribute('xml:space', 'preserve') text.textContent = value run.appendChild(text) paragraph.appendChild(run) } const appendField = (paragraph, name, fallback) => { for (const kind of ['begin', 'separate', 'end']) { const run = element('r') const marker = element('fldChar') marker.setAttributeNS(wordNamespace, 'w:fldCharType', kind) run.appendChild(marker) paragraph.appendChild(run) if (kind === 'begin') appendRun(paragraph, 'instrText', ` ${name} `, true) if (kind === 'separate') appendRun(paragraph, 't', fallback) } } const alignedReport = element('p') const reportProperties = element('pPr') const reportJustification = element('jc') reportJustification.setAttributeNS(wordNamespace, 'w:val', 'right') reportProperties.appendChild(reportJustification) alignedReport.appendChild(reportProperties) for (const run of reportRuns.slice(labelIndex)) alignedReport.appendChild(run.cloneNode(true)) if (missingCode) appendRun(alignedReport, 't', ` ${reportCode.trim()}`, true) const pageParagraph = element('p') const pageProperties = reportProperties.cloneNode(true) const indent = element('ind') indent.setAttributeNS(wordNamespace, 'w:right', '300') pageProperties.appendChild(indent) pageParagraph.appendChild(pageProperties) appendRun(pageParagraph, 't', '第 ', true) appendField(pageParagraph, 'PAGE', '1') appendRun(pageParagraph, 't', ' 页 共 ', true) appendField(pageParagraph, 'NUMPAGES', '4') appendRun(pageParagraph, 't', ' 页', true) while (header.firstChild) header.removeChild(header.firstChild) header.appendChild(alignedReport) header.appendChild(pageParagraph) return new XMLSerializer().serializeToString(document) } function chineseThemeFont(themeXml, kind) { const fontGroup = themeXml.match(new RegExp(`]*>[\\s\\S]*?<\\/a:${kind}Font>`))?.[0] if (!fontGroup) return null // Word 按中文语言区域选 Hans;主题中的 a:ea 常为空,不能把它当成实际字体。 const hansFont = [...fontGroup.matchAll(/]*\/>/g)] .find(([tag]) => /\bscript="Hans"/.test(tag))?.[0] return hansFont?.match(/\btypeface="([^"]+)"/)?.[1] || fontGroup.match(/]*\btypeface="([^"]+)"/)?.[1] || null } function materializeChineseThemeFonts(xml, themeFonts) { let changed = false const normalized = xml.replace(runFontsPattern, tag => { let result = tag for (const script of ['ascii', 'hAnsi', 'eastAsia', 'cs']) { const themeAttribute = new RegExp(`\\s+w:${script}Theme="(majorEastAsia|minorEastAsia)"`) const theme = result.match(themeAttribute)?.[1] const font = theme === 'majorEastAsia' ? themeFonts.major : themeFonts.minor if (!font || !theme) continue // OOXML 中主题字体优先于同脚本的显式字体;解析成 Word 实际使用的字体。 result = result.replace(themeAttribute, '') const directAttribute = new RegExp(`\\s+w:${script}="[^"]*"`) result = directAttribute.test(result) ? result.replace(directAttribute, ` w:${script}="${font}"`) : result.replace(/\s*\/>$/, ` w:${script}="${font}"/>`) changed = true } return result }) return changed ? normalized : null } function alignResultTable(xml) { let changed = false const documentXml = xml.replace(resultTablePattern, (table, offset) => { if (!table.includes('标准要求值') || !table.includes('检测结果')) return table const position = table.match(floatingPositionPattern)?.[0] if (!position?.includes('w:horzAnchor="page"')) return table // 旧版结果表使用相对页面的固定坐标;沿用前一张正文表的缩进才能保持同一左边线。 const precedingTables = [...xml.slice(0, offset).matchAll(resultTablePattern)] const precedingIndent = precedingTables.reverse() .map(match => match[0].match(tableIndentPattern)?.[0]?.match(/w:w="(-?\d+)"/)?.[1]) .find(value => value != null) const indent = precedingIndent ?? '-670' changed = true return table.replace(position, '').replace(tableIndentPattern, ``) }) return changed ? documentXml : null } export async function normalizeReportDocxForEditor(blob, reportCode) { const zip = await JSZip.loadAsync(blob) const documentPart = zip.file('word/document.xml') if (!documentPart) return blob const documentXml = await documentPart.async('string') const themePart = zip.file('word/theme/theme1.xml') const themeXml = themePart ? await themePart.async('string') : '' const themeFonts = { major: chineseThemeFont(themeXml, 'major'), minor: chineseThemeFont(themeXml, 'minor') } let normalizedXml = alignResultTable(documentXml) || documentXml normalizedXml = materializeChineseThemeFonts(normalizedXml, themeFonts) || normalizedXml let changed = normalizedXml !== documentXml if (changed) zip.file('word/document.xml', normalizedXml) const stylesPart = zip.file('word/styles.xml') if (stylesPart) { const stylesXml = await stylesPart.async('string') const normalizedStyles = materializeChineseThemeFonts(stylesXml, themeFonts) if (normalizedStyles) { zip.file('word/styles.xml', normalizedStyles) changed = true } } const headerNames = Object.keys(zip.files).filter(name => /^word\/header\d+\.xml$/.test(name)) for (const headerName of headerNames) { const headerXml = await zip.file(headerName).async('string') const normalizedHeader = alignReportHeader(headerXml, reportCode) if (normalizedHeader) { zip.file(headerName, normalizedHeader) changed = true } } if (!changed) return blob return zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }) }