liding
8 小时以前 49cdc0884a9a4381cd968cffff65e2a332bc675a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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
const tableRowPattern = /<w:tr(?:\s[^>]*)?>[\s\S]*?<\/w:tr>/g
const tableCellPattern = /<w:tc(?:\s[^>]*)?>[\s\S]*?<\/w:tc>/g
 
function fitCoverCustomer(xml) {
  let changed = false
  const normalized = xml.replace(tableRowPattern, row => {
    if (changed) return row
    const cells = [...row.matchAll(tableCellPattern)]
    if (cells.length !== 2) return row
    const cellText = cell => [...cell.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g)]
      .map(match => match[1]).join('')
    if (cellText(cells[0][0]).replace(/[\s::]/g, '') !== '委托单位') return row
 
    const valueCell = cells[1][0]
    const customer = cellText(valueCell)
    const widthTag = valueCell.match(/<w:tcW\b[^>]*>/)?.[0]
    const widthTwips = Number(widthTag?.match(/\bw:w="(\d+)"/)?.[1])
    const fontHalfPoints = Number(valueCell.match(/<w:sz\b[^>]*\bw:val="(\d+)"/)?.[1])
    if (!customer || customer.includes('{{') || !widthTwips || fontHalfPoints !== 32) return row
 
    // 封面下划线是单元格底边框。16 磅的长单位名称刚好占满列宽时,
    // PDF 排版器可能将段落结束符挤到第二行,导致表格行和下划线下移。
    const units = [...customer].reduce((sum, char) => sum +
      (/\s/.test(char) ? 0.35 : char.codePointAt(0) > 0x2e80 ? 1 : 0.6), 0)
    const availablePoints = widthTwips / 20 - 10
    if (units * fontHalfPoints / 2 <= availablePoints) return row
    const fittedHalfPoints = Math.max(24, Math.floor(availablePoints / units * 2))
    const fittedCell = valueCell.replace(/<w:sz(?:Cs)?\b[^>]*\/>/g, tag =>
      tag.replace(/\bw:val="32"/, `w:val="${fittedHalfPoints}"`))
    if (fittedCell === valueCell) return row
    changed = true
    return row.replace(valueCell, fittedCell)
  })
  return changed ? normalized : null
}
 
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 = fitCoverCustomer(normalizedXml) || normalizedXml
  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' })
}