src/utils/reportDocxLayout.js
@@ -4,6 +4,41 @@
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 的横向锚点。
@@ -129,6 +164,21 @@
  return changed ? documentXml : null
}
function clearDefaultSequenceUnderlines(xml) {
  return xml.replace(resultTablePattern, table => {
    if (!table.includes('序号') || !/(检测结果|检验结果)/.test(table)) return table
    return table.replace(tableRowPattern, row => row.replace(tableCellPattern, (cell, offset) => {
      if (offset !== row.indexOf('<w:tc')) return cell
      const text = [...cell.matchAll(/<w:t\b(?![^>]*\/>)[^>]*>([\s\S]*?)<\/w:t>/g)]
        .map(match => match[1]).join('').trim()
      if (!/^\d+$/.test(text)) return cell
      // 旧报告中的 <w:u w:color="auto"/> 默认会被解释成单下划线。
      // 显式设置了类型的下划线保留,允许用户自行修改序号格式。
      return cell.replace(/<w:u\b[^>]*\/>/g, tag => /\bw:val=/.test(tag) ? tag : '')
    }))
  })
}
export async function normalizeReportDocxForEditor(blob, reportCode) {
  const zip = await JSZip.loadAsync(blob)
  const documentPart = zip.file('word/document.xml')
@@ -138,6 +188,8 @@
  const themeXml = themePart ? await themePart.async('string') : ''
  const themeFonts = { major: chineseThemeFont(themeXml, 'major'), minor: chineseThemeFont(themeXml, 'minor') }
  let normalizedXml = alignResultTable(documentXml) || documentXml
  normalizedXml = clearDefaultSequenceUnderlines(normalizedXml)
  normalizedXml = fitCoverCustomer(normalizedXml) || normalizedXml
  normalizedXml = materializeChineseThemeFonts(normalizedXml, themeFonts) || normalizedXml
  let changed = normalizedXml !== documentXml
  if (changed) zip.file('word/document.xml', normalizedXml)