Prechádzať zdrojové kódy

fix(web-admin): keep contract header meta side-by-side in Word export

Convert flex+absolute rows (供方/合同编号, 需方/签订地点) into borderless two-column tables before html-to-docx.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 mesiacov pred
rodič
commit
da2f36af7f

+ 42 - 3
web-admin/src/utils/crm/contract-html-to-docx.test.ts

@@ -3,6 +3,7 @@ import { describe, it } from 'node:test'
 import {
   buildContractDocxFileName,
   inlineRemoteImagesAsDataUrls,
+  normalizeHeaderMetaFlexLayoutForDocx,
   normalizePartyFlexLayoutForDocx,
   parseContractSupplierCode,
   prepareContractHtmlForDocx,
@@ -57,6 +58,39 @@ describe('toAbsoluteAssetUrls', () => {
   })
 })
 
+describe('normalizeHeaderMetaFlexLayoutForDocx', () => {
+  it('converts supplier/contractNo and demander/signPlace rows into side-by-side tables', () => {
+    const html = `
+      <div style="display: flex; margin-bottom: 0; position: relative;">
+        <div style="flex: 1;">供方:安庆市山峡化工有限公司</div>
+        <div style="position: absolute; right: 0px; min-width: 300px;">合同编号:SX/X-JT20260828004</div>
+      </div>
+      <div style="display: flex; margin-bottom: 0; position: relative;">
+        <div style="flex: 1;">需方:安徽海康药业股份有限公司</div>
+        <div style="position: absolute; right: 0px; min-width: 300px;">签订地点:安徽安庆</div>
+      </div>`
+    const out = normalizeHeaderMetaFlexLayoutForDocx(html)
+    assert.equal((out.match(/<table/g) || []).length, 2)
+    assert.equal(out.includes('text-align:right'), true)
+    assert.equal(out.includes('position: absolute'), false)
+    assert.equal(out.includes('供方:安庆市山峡化工有限公司'), true)
+    assert.equal(out.includes('合同编号:SX/X-JT20260828004'), true)
+    assert.equal(out.includes('需方:安徽海康药业股份有限公司'), true)
+    assert.equal(out.includes('签订地点:安徽安庆'), true)
+  })
+
+  it('does not rewrite bottom party space-between block', () => {
+    const html = `
+      <div style="display: flex; justify-content: space-between; margin-top: 10px;">
+        <div style="flex: 1;"><div>供方</div><div>单位名称(章):甲</div></div>
+        <div style="flex: 1;"><div>需方</div><div>单位名称(章):乙</div></div>
+      </div>`
+    const out = normalizeHeaderMetaFlexLayoutForDocx(html)
+    assert.equal(out.includes('justify-content: space-between'), true)
+    assert.equal(out.includes('<table'), false)
+  })
+})
+
 describe('normalizePartyFlexLayoutForDocx', () => {
   it('converts bottom party flex columns into a two-cell table', () => {
     const html = `
@@ -104,8 +138,12 @@ describe('prepareContractHtmlForDocx', () => {
     assert.equal(out.includes('正文'), true)
   })
 
-  it('normalizes party flex layout during prepare', () => {
+  it('normalizes header meta and party flex layout during prepare', () => {
     const html = `
+      <div style="display: flex; margin-bottom: 0; position: relative;">
+        <div style="flex: 1;">供方:甲</div>
+        <div style="position: absolute; right: 0px;">合同编号:NO1</div>
+      </div>
       <div style="display: flex; justify-content: space-between; margin-top: 10px;">
         <div style="flex: 1;"><div>供方</div><div>A</div></div>
         <div style="flex: 1;"><div>需方</div><div>B</div></div>
@@ -115,8 +153,9 @@ describe('prepareContractHtmlForDocx', () => {
       contractNo: 'SX/X-JT1',
       origin: 'http://localhost:3006'
     })
-    assert.equal(out.includes('<table'), true)
-    assert.equal(/display:\s*flex/.test(out), false)
+    assert.equal((out.match(/<table/g) || []).length >= 2, true)
+    assert.equal(/position:\s*absolute/.test(out), false)
+    assert.equal(/justify-content:\s*space-between/.test(out), false)
   })
 })
 

+ 64 - 16
web-admin/src/utils/crm/contract-html-to-docx.ts

@@ -158,12 +158,12 @@ export function toAbsoluteAssetUrls(html: string, origin: string): string {
 }
 
 /**
- * 从 openIdx 处的 `<div` 起做括号匹配,返回整段与 innerHTML。
+ * 从 openIdx 处的 `<div` 起做括号匹配,返回整段、开标签与 innerHTML。
  */
 function takeBalancedDiv(
   html: string,
   openIdx: number
-): { full: string; inner: string; end: number } | null {
+): { full: string; openTag: string; inner: string; end: number } | null {
   const slice = html.slice(openIdx)
   if (!/^<div\b/i.test(slice)) return null
   const openTagEnd = html.indexOf('>', openIdx)
@@ -186,6 +186,7 @@ function takeBalancedDiv(
         const end = nextClose + 6
         return {
           full: html.slice(openIdx, end),
+          openTag: html.slice(openIdx, openTagEnd + 1),
           inner: html.slice(openTagEnd + 1, nextClose),
           end
         }
@@ -196,6 +197,63 @@ function takeBalancedDiv(
   return null
 }
 
+function collectTopLevelDivChildren(
+  innerHtml: string
+): Array<{ openTag: string; inner: string }> {
+  const children: Array<{ openTag: string; inner: string }> = []
+  let pos = 0
+  const lower = innerHtml.toLowerCase()
+  while (pos < innerHtml.length) {
+    const divIdx = lower.indexOf('<div', pos)
+    if (divIdx < 0) break
+    const child = takeBalancedDiv(innerHtml, divIdx)
+    if (!child) break
+    children.push({ openTag: child.openTag, inner: child.inner })
+    pos = child.end
+  }
+  return children
+}
+
+/**
+ * 顶部元信息(供方/合同编号、需方/签订地点)在预览里用 flex + absolute 右对齐;
+ * Word 不认,导出前改为无边框双列表格(左左文、右右对齐)。
+ */
+export function normalizeHeaderMetaFlexLayoutForDocx(html: string): string {
+  const openRe = /<div\b(?=[^>]*style\s*=\s*["'][^"']*display:\s*flex)[^>]*>/gi
+  const opens: number[] = []
+  let m: RegExpExecArray | null
+  while ((m = openRe.exec(html)) !== null) {
+    opens.push(m.index)
+  }
+
+  let out = html
+  for (let oi = opens.length - 1; oi >= 0; oi -= 1) {
+    const index = opens[oi]
+    const flex = takeBalancedDiv(out, index)
+    if (!flex) continue
+    // 底部供需方双栏用 space-between,交给 normalizePartyFlexLayoutForDocx
+    if (/justify-content:\s*space-between/i.test(flex.openTag)) continue
+
+    const children = collectTopLevelDivChildren(flex.inner)
+    if (children.length !== 2) continue
+
+    const [left, right] = children
+    const absoluteRight =
+      /position:\s*absolute/i.test(right.openTag) && /right:\s*/i.test(right.openTag)
+    const labelPair =
+      (left.inner.includes('供方') && right.inner.includes('合同编号')) ||
+      (left.inner.includes('需方') && right.inner.includes('签订地点'))
+    if (!absoluteRight && !labelPair) continue
+    // 避免误伤底部签署区(含「单位名称」等字段)
+    if (left.inner.includes('单位名称') || right.inner.includes('单位名称')) continue
+
+    const table = `<table style="width:100%;border-collapse:collapse;margin:0;table-layout:fixed;font-size:12px;line-height:1.5;"><tbody><tr><td style="width:50%;vertical-align:top;border:none;padding:0;">${left.inner}</td><td style="width:50%;vertical-align:top;border:none;padding:0;text-align:right;">${right.inner}</td></tr></tbody></table>`
+    out = out.slice(0, index) + table + out.slice(flex.end)
+  }
+
+  return out
+}
+
 /**
  * 合同底部供方/需方在预览里是 flex 左右栏;Word 不认 flex,导出前改为双列表格。
  */
@@ -214,22 +272,11 @@ export function normalizePartyFlexLayoutForDocx(html: string): string {
     const flex = takeBalancedDiv(out, index)
     if (!flex) continue
 
-    const children: string[] = []
-    let pos = 0
-    const innerLower = flex.inner.toLowerCase()
-    while (pos < flex.inner.length && children.length < 2) {
-      const divIdx = innerLower.indexOf('<div', pos)
-      if (divIdx < 0) break
-      const child = takeBalancedDiv(flex.inner, divIdx)
-      if (!child) break
-      children.push(child.inner)
-      pos = child.end
-    }
-
+    const children = collectTopLevelDivChildren(flex.inner)
     if (children.length < 2) continue
-    if (!children[0].includes('供方') || !children[1].includes('需方')) continue
+    if (!children[0].inner.includes('供方') || !children[1].inner.includes('需方')) continue
 
-    const table = `<table style="width:100%;border-collapse:collapse;margin-top:10px;table-layout:fixed;"><tbody><tr><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[0]}</td><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[1]}</td></tr></tbody></table>`
+    const table = `<table style="width:100%;border-collapse:collapse;margin-top:10px;table-layout:fixed;"><tbody><tr><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[0].inner}</td><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[1].inner}</td></tr></tbody></table>`
     out = out.slice(0, index) + table + out.slice(flex.end)
   }
 
@@ -253,6 +300,7 @@ export function prepareContractHtmlForDocx(options: {
   }
 
   html = toAbsoluteAssetUrls(html, origin)
+  html = normalizeHeaderMetaFlexLayoutForDocx(html)
   html = normalizePartyFlexLayoutForDocx(html)
 
   return html