]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*
]*>([\s\S]*?)<\/p>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)>\s*
]*class="delivery-slip)[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(/<(td|th)([^>]*delivery-slip-product-grid-cell[^>]*)\s+height="[^"]*"/gi, '<$1$2')
result = result.replace(/<(tr)([^>]*class="[^"]*delivery-slip-product[^"]*"[^>]*)\s+height="[^"]*"/gi, '<$1$2')
return result
}
function extractDeliverySlipGridCellContent(cell: HTMLTableCellElement): string {
let content = cell.innerHTML.trim()
const onlyChild = cell.firstElementChild
if (onlyChild && cell.childElementCount === 1) {
const tag = onlyChild.tagName
if (
tag === 'P' ||
tag === 'DIV' ||
tag === 'SPAN' ||
onlyChild.classList.contains('delivery-slip-pdf-grid-inner') ||
onlyChild.classList.contains('delivery-slip-pdf-grid-text')
) {
content = onlyChild.innerHTML.trim()
}
}
return formatDeliverySlipProductGridCellContent(content)
}
function applyDeliverySlipPdfGridCellStyles(cell: HTMLTableCellElement, isHeader: boolean): void {
const content = extractDeliverySlipGridCellContent(cell)
cell.innerHTML = content || ' '
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', isHeader ? PRODUCT_GRID_PDF_TH_PADDING : PRODUCT_GRID_PDF_TD_PADDING, 'important')
cell.style.setProperty('text-align', 'center', 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_GRID_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'middle', 'important')
if (isHeader) {
cell.style.setProperty('font-weight', 'bold', 'important')
}
}
function applyDeliverySlipPdfFooterCellStyles(cell: HTMLTableCellElement): void {
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', PRODUCT_FOOTER_PDF_PADDING, 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'top', 'important')
cell.style.setProperty('text-align', 'left', 'important')
}
function unwrapDeliverySlipPdfCellParagraph(cell: HTMLTableCellElement): void {
const onlyChild = cell.firstElementChild
if (onlyChild?.tagName === 'P' && cell.childElementCount === 1) {
cell.innerHTML = onlyChild.innerHTML.trim() || ' '
}
}
function prepareDeliverySlipPdfRemarkCell(cell: HTMLTableCellElement): void {
unwrapDeliverySlipPdfCellParagraph(cell)
applyDeliverySlipPdfFooterCellStyles(cell)
}
function prepareDeliverySlipPdfTermsCell(cell: HTMLTableCellElement): void {
applyDeliverySlipPdfFooterCellStyles(cell)
const terms = cell.querySelector('.delivery-slip-terms-text') as HTMLElement | null
if (terms) {
terms.style.setProperty('margin', '0', 'important')
terms.style.setProperty('padding', '0', 'important')
terms.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareDeliverySlipPdfSealCell(cell: HTMLTableCellElement): void {
applyDeliverySlipPdfFooterCellStyles(cell)
const block = cell.querySelector('.delivery-slip-seal-block') as HTMLElement | null
const lines = cell.querySelector('.delivery-slip-seal-lines') as HTMLElement | null
if (block) {
block.style.setProperty('padding', '0', 'important')
block.style.setProperty('margin', '0', 'important')
block.style.setProperty('position', 'relative', 'important')
}
if (lines) {
lines.style.setProperty('margin', '0', 'important')
lines.style.setProperty('padding', '0', 'important')
lines.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareDeliverySlipPdfSignValues(root: HTMLElement): void {
root.querySelectorAll('.delivery-slip-sign-value').forEach((node) => {
const el = node as HTMLElement
el.style.setProperty('display', 'inline-block', 'important')
el.style.setProperty('padding', SIGN_VALUE_PDF_PADDING, 'important')
el.style.setProperty('line-height', '1.35', 'important')
el.style.setProperty('vertical-align', 'baseline', 'important')
el.style.setProperty('border', 'none', 'important')
el.style.setProperty('border-bottom', SIGN_UNDERLINE, 'important')
el.style.setProperty('box-sizing', 'border-box', 'important')
})
}
/** PDF 截图前:将原生 checkbox 替换为 canvas 方框图 + 文字(html2canvas 对 flex/SVG 对齐不稳定) */
const deliverySlipPdfCheckboxImgCache: { empty?: string; checked?: string } = {}
function buildDeliverySlipPdfCheckboxImgDataUrl(checked: boolean): string {
const key = checked ? 'checked' : 'empty'
if (deliverySlipPdfCheckboxImgCache[key]) return deliverySlipPdfCheckboxImgCache[key]!
const size = 32
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) return ''
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size, size)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 2
ctx.strokeRect(1, 1, size - 2, size - 2)
if (checked) {
ctx.beginPath()
ctx.moveTo(8, 17)
ctx.lineTo(14, 23)
ctx.lineTo(24, 8)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 3.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
const url = canvas.toDataURL('image/png')
deliverySlipPdfCheckboxImgCache[key] = url
return url
}
function buildDeliverySlipPdfTypeCheckItemHtml(checked: boolean, labelText: string): string {
const imgSrc = buildDeliverySlipPdfCheckboxImgDataUrl(checked)
if (!imgSrc) return `
${labelText}`
return `
${labelText}`
}
function applyDeliverySlipPdfTypeCheckboxLayout(root: HTMLElement): void {
root.querySelectorAll('.delivery-slip-type-check-wrap').forEach((node) => {
const wrap = node as HTMLElement
wrap.classList.add('delivery-slip-type-check-wrap-pdf')
wrap.style.setProperty('display', 'table', 'important')
wrap.style.setProperty('width', '100%', 'important')
wrap.style.setProperty('table-layout', 'auto', 'important')
wrap.style.setProperty('border-collapse', 'collapse', 'important')
wrap.style.setProperty('align-items', 'unset', 'important')
})
root.querySelectorAll('.delivery-slip-type-check-spacer').forEach((node) => {
const spacer = node as HTMLElement
spacer.classList.add('delivery-slip-type-check-spacer-pdf')
spacer.style.setProperty('display', 'table-cell', 'important')
spacer.style.setProperty('visibility', 'hidden', 'important')
spacer.style.setProperty('white-space', 'nowrap', 'important')
spacer.style.setProperty('vertical-align', 'middle', 'important')
spacer.style.setProperty('padding', '0', 'important')
spacer.style.setProperty('border', 'none', 'important')
})
root.querySelectorAll('.delivery-slip-type-check-row').forEach((node) => {
const row = node as HTMLElement
row.classList.add('delivery-slip-type-check-row-pdf')
row.style.setProperty('display', 'table-cell', 'important')
row.style.setProperty('vertical-align', 'middle', 'important')
row.style.setProperty('line-height', '20px', 'important')
row.style.setProperty('font-size', '20px', 'important')
row.style.setProperty('flex', 'unset', 'important')
row.style.removeProperty('gap')
row.style.removeProperty('align-items')
row.style.removeProperty('justify-content')
})
root.querySelectorAll('.delivery-slip-type-check-item-pdf').forEach((node) => {
const item = node as HTMLElement
item.style.setProperty('display', 'inline-block', 'important')
item.style.setProperty('white-space', 'nowrap', 'important')
item.style.setProperty('font-family', "SimSun, 'Songti SC', serif", 'important')
item.style.setProperty('font-size', '20px', 'important')
item.style.setProperty('line-height', '20px', 'important')
item.style.setProperty('height', '20px', 'important')
item.style.setProperty('margin-right', '18px', 'important')
item.style.setProperty('vertical-align', 'middle', 'important')
const img = item.querySelector('img.delivery-slip-type-check-img-pdf') as HTMLImageElement | null
if (img) {
img.style.setProperty('display', 'inline-block', 'important')
img.style.setProperty('width', '16px', 'important')
img.style.setProperty('height', '16px', 'important')
img.style.setProperty('margin', '0 4px 0 0', 'important')
img.style.setProperty('padding', '0', 'important')
img.style.setProperty('border', 'none', 'important')
img.style.setProperty('vertical-align', 'middle', 'important')
}
const text = item.querySelector('.delivery-slip-type-check-text-pdf') as HTMLElement | null
if (text) {
text.style.setProperty('display', 'inline-block', 'important')
text.style.setProperty('font-size', '20px', 'important')
text.style.setProperty('line-height', '20px', 'important')
text.style.setProperty('vertical-align', 'middle', 'important')
text.style.setProperty('position', 'relative', 'important')
text.style.setProperty('top', '-12px', 'important')
}
})
}
function prepareDeliverySlipPdfTypeCheckboxes(root: HTMLElement): void {
root.querySelectorAll('label.delivery-slip-type-check-item').forEach((node) => {
const label = node as HTMLElement
const input = label.querySelector('input.delivery-slip-type-check-input') as HTMLInputElement | null
const checked = input?.checked ?? label.querySelector('.delivery-slip-type-check-box')?.textContent?.includes('✓') ?? false
const labelText = (label.textContent || '').replace(/[✓☑□☐]/g, '').trim()
const html = buildDeliverySlipPdfTypeCheckItemHtml(checked, labelText)
const wrapper = document.createElement('div')
wrapper.innerHTML = html.trim()
const replacement = wrapper.firstElementChild
if (replacement) {
label.replaceWith(replacement)
}
})
applyDeliverySlipPdfTypeCheckboxLayout(root)
}
/** PDF 截图前:物料网格 + 备注/条款/地址行 + 签字区统一样式 */
export function prepareDeliverySlipPdfGridCells(root: HTMLElement): void {
root.querySelectorAll('td.delivery-slip-product-grid-cell, th.delivery-slip-product-grid-cell').forEach((node) => {
applyDeliverySlipPdfGridCellStyles(node as HTMLTableCellElement, node.tagName === 'TH')
})
root.querySelectorAll('td.delivery-slip-terms-cell').forEach((node) => {
prepareDeliverySlipPdfTermsCell(node as HTMLTableCellElement)
})
root.querySelectorAll('td.delivery-slip-product-cell:not(.delivery-slip-product-grid-cell)').forEach((node) => {
const cell = node as HTMLTableCellElement
if (cell.classList.contains('delivery-slip-terms-cell')) return
if (cell.querySelector('.delivery-slip-seal-block')) {
prepareDeliverySlipPdfSealCell(cell)
return
}
if (/备注\s*[::]/.test(cell.textContent || '')) {
prepareDeliverySlipPdfRemarkCell(cell)
}
})
root.querySelectorAll('tr').forEach((row) => {
if (!row.querySelector('.delivery-slip-product-cell')) return
row.removeAttribute('height')
row.style.removeProperty('height')
row.style.removeProperty('min-height')
})
prepareDeliverySlipPdfSignValues(root)
prepareDeliverySlipPdfTypeCheckboxes(root)
// 亚像素 border 在 Windows DPI 下易变粗;截图前改为 1px 背景单线
applyPdfStableTableCellBorders(root.querySelectorAll('.delivery-slip-product-cell'))
}
/** 备注、条款、印章行:统一 0.3px 全边框(兼容已保存模板,与物料网格衔接) */
function normalizeDeliverySlipProductTableFooter(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
let result = html
result = result.replace(/
]*colspan="[78]"[^>]*)>(\s*)备注:/g, (_match, attrs, space) => {
const withSpan = String(attrs).replace(/colspan="[78]"/, 'colspan="8"')
const patched = patchProductFooterCellAttrs(
withSpan,
'delivery-slip-product-cell',
PRODUCT_TABLE_CELL_LEFT
)
return ` | ${space}备注:`
})
result = result.replace(
/ | ]*colspan="[456]"[^>]*)>\s*( {
const withSpan = String(attrs).replace(/colspan="[456]"/, 'colspan="5"')
const patched = patchProductFooterCellAttrs(
withSpan,
'delivery-slip-product-cell delivery-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
return ` \n ${contentStart}`
}
)
result = result.replace(
/ | ]*colspan="[23]"[^>]*)>\s* {
const withSpan = String(attrs).replace(/colspan="[23]"/, 'colspan="3"')
const patched = patchProductFooterCellAttrs(withSpan, 'delivery-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return ` \n {
let nextAttrs = attrs
if (nextAttrs.includes('class="')) {
nextAttrs = nextAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('delivery-slip-terms-cell') ? `class="${cls}"` : `class="${cls} delivery-slip-terms-cell"`
)
} else {
nextAttrs = `${nextAttrs} class="delivery-slip-product-cell delivery-slip-terms-cell"`
}
if (nextAttrs.includes('style="')) {
nextAttrs = nextAttrs.replace(/style="[^"]*"/, `style="${PRODUCT_TERMS_CELL_LEFT}"`)
} else {
nextAttrs = `${nextAttrs} style="${PRODUCT_TERMS_CELL_LEFT}"`
}
return nextAttrs
}
let result = html.replace(
/ ]*)>\s* {
if (attrs.includes('delivery-slip-terms-cell') && attrs.includes('border:0.3px solid #000') && /colspan="5"/.test(match)) {
return match
}
return ` \n {
if (style.includes('padding:0;') && !style.includes('min-height')) return match
let nextStyle = style.replace(/min-height:\d+px;/g, '').replace(/padding:[^;]*;/g, '')
nextStyle = `padding:0;${nextStyle}`
return ` ]*>\s*) [\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 条款区两行间距与括号内手写空白(兼容已保存模板) */
function normalizeDeliverySlipTermsText(html: string): string {
if (!html.includes('delivery-slip-doc') || !html.includes('1.本送货单')) {
return html
}
const termsHtml = buildDeliverySlipTermsHtml()
if (html.includes('delivery-slip-terms-text')) {
return html.replace(//, termsHtml.trim())
}
return html.replace(
/1\.本送货单如购销双方未签订合同,视为有效;[\s\S]*?结存\([^)]*\)个;/,
termsHtml.trim()
)
}
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshDeliverySlipGapBeforeProduct(html: string): string {
return html
.replace(
/ [\s\S]*?<\/tr>/,
` \n | | \n `
)
.replace(
/ ]*>[\s\S]*?<\/div>/,
` `
)
}
/** 在收货联系方式行与物料表之间插入间距行(兼容已保存模板) */
function normalizeDeliverySlipGapBeforeProduct(html: string): string {
if (!html.includes('delivery-slip-doc')) {
return html
}
if (html.includes('delivery-slip-gap-before-product')) {
return refreshDeliverySlipGapBeforeProduct(html)
}
const gapRow = ` | | \n `
const productHeader = `(?:单据编号|物料编号)`
const withComment = html.replace(
new RegExp(
`(收货联系方式:[\\s\\S]*?<\\/tr>\\s*)(\s* ]*>)[\s\S]*?(<\/td>)/,
'$1{{sideLabels}}$2'
)
return result
}
/** 将占位符替换为计划最新数据 */
export function replaceDeliverySlipPlaceholders(
htmlContent: string,
plan: Plan,
ctx?: DeliverySlipRenderContext
): string {
if (!htmlContent || !plan) return htmlContent
const replacements: Record = {
'{{companyName}}': DELIVERY_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': buildReceivingUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{receiveContact}}': '',
'{{driverInfo}}': '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx),
'{{remark}}': formatRemarkForSlipHtml(plan.remark),
'{{companyAddress}}': DELIVERY_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': DELIVERY_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': DELIVERY_SLIP_DEFAULTS.contactPhone,
'{{companySeal}}': buildDeliverySlipCompanySealHtml(),
'{{sideLabels}}': buildDeliverySlipSideLabelsHtml()
}
let result = normalizeDeliverySlipDocLayout(
normalizeDeliverySlipSealPlaceholder(
normalizeDeliverySlipSignRow(
normalizeDeliverySlipTermsCell(
normalizeDeliverySlipTermsText(
normalizeDeliverySlipGapBeforeSign(
normalizeDeliverySlipProductTableFooter(
normalizeDeliverySlipProductColWidths(
normalizeDeliverySlipProductGridCell(
normalizeDeliverySlipProductPlanNoColumn(
normalizeDeliverySlipInfoLayout(
normalizeDeliverySlipGapBeforeProduct(
normalizeDeliverySlipTypeCheckRow(normalizeDeliverySlipSideLabelsPlaceholder(htmlContent))
)
)
)
)
)
)
)
)
)
)
)
)
for (const [placeholder, value] of Object.entries(replacements)) {
result = result.split(placeholder).join(value)
}
return result
}
/** 保存模板前:将动态字段还原为占位符 */
export function restoreDeliverySlipPlaceholders(template: string, plan: Plan): string {
let result = template
const dynamicReplacements: Record = {
'{{companyName}}': DELIVERY_SLIP_DEFAULTS.companyName,
'{{receivingUnit}}': buildReceivingUnit(plan),
'{{planNo}}': plan.planNo || '',
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate),
'{{remark}}': plan.remark || '',
'{{companyAddress}}': DELIVERY_SLIP_DEFAULTS.companyAddress,
'{{companyPhone}}': DELIVERY_SLIP_DEFAULTS.companyPhone,
'{{contactPhone}}': DELIVERY_SLIP_DEFAULTS.contactPhone
}
for (const [placeholder, currentValue] of Object.entries(dynamicReplacements)) {
if (!currentValue) continue
const escaped = currentValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'g'), placeholder)
}
// 产品明细行还原为占位符(表头「实收数量」之后、页脚「备注」之前;兼容分表与旧一体表)
result = result.replace(
/(| ]*>实收数量<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s* | \s*]*>\s*备注:)/,
'$1{{productTable}}$3'
)
// 右侧联次还原为占位符
result = result.replace(//, '{{sideLabels}}')
result = result.replace(//, '{{sideLabels}}')
// 印章还原为占位符(对齐合同管理打印弹窗)
result = result.replace(/ ![]() ]*alt="印章"[^>]*>/gi, '')
if (result.includes('delivery-slip-seal-block') && !result.includes('{{companySeal}}')) {
result = result.replace(
/( ]*>)/,
'$1\n \n {{companySeal}}\n '
)
}
result = result.replace(/(\{\{companySeal\}\}\s*){2,}/g, '{{companySeal}}\n')
return result
}
| | | | | | |