import JSZip from 'jszip'
|
|
const resultTablePattern = /<w:tbl(?:\s[^>]*)?>[\s\S]*?<\/w:tbl>/g
|
const floatingPositionPattern = /<w:tblpPr\b[^>]*\/>/
|
const tableIndentPattern = /<w:tblInd\b[^>]*\/>/
|
const runFontsPattern = /<w:rFonts\b[^>]*\/>/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(`<a:${kind}Font\\b[^>]*>[\\s\\S]*?<\\/a:${kind}Font>`))?.[0]
|
if (!fontGroup) return null
|
// Word 按中文语言区域选 Hans;主题中的 a:ea 常为空,不能把它当成实际字体。
|
const hansFont = [...fontGroup.matchAll(/<a:font\b[^>]*\/>/g)]
|
.find(([tag]) => /\bscript="Hans"/.test(tag))?.[0]
|
return hansFont?.match(/\btypeface="([^"]+)"/)?.[1]
|
|| fontGroup.match(/<a:ea\b[^>]*\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,
|
`<w:tblInd w:w="${indent}" w:type="dxa"/>`)
|
})
|
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' })
|
}
|