feat(signing): 前端生成签署后 PDF 原型

This commit is contained in:
yelan
2026-09-16 17:24:02 +08:00
parent 2279f7a624
commit 51ec0f393d
6 changed files with 487 additions and 1 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* 前端生成「签署后 PDF」(原型阶段)。
*
* 真实实现里 PDF 由服务端生成;这里用 html2canvas 把已经渲染好的文书截图,
* 再按 A4 分页拼进 jsPDF,让前端能先看到成品效果:
* ① 截图用固定版心宽度:实时容器有固定高度、内边距,且宽度随工作台面板变化,
* 直接截图既截不全、版面也会跟着变;
* ② 固定页眉在每页重复:页眉是系统按 HIS 数据渲染的,服务端生成 PDF 时同样要按页重复;
* ③ 页码只写 "1 / 2" 这类 ASCII:jsPDF 内置字体不含中文,
* 画中文页码需要内嵌字体(体积会明显变大),这一步留给服务端用真字体实现。
*
* 输出为位图 PDF:文本不可选中,仅供原型演示;正式文书以服务端生成的 PDF 为准。
*/
const PAGE_WIDTH_MM = 210
const PAGE_HEIGHT_MM = 297
const MARGIN_X_MM = 15
const MARGIN_TOP_MM = 12
const MARGIN_BOTTOM_MM = 14
const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_X_MM * 2
const CONTENT_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_TOP_MM - MARGIN_BOTTOM_MM
/** 截图版心宽度(px):约等于 180mm @96dpi,让 PDF 版面接近实际纸张 */
const CAPTURE_WIDTH_PX = 680
/** 截图倍率:2 倍下正文与小字都还清晰 */
const CAPTURE_SCALE = 2
/** 量不到页眉时的兜底高度(px) */
const FALLBACK_HEADER_PX = 46
/** 页眉高到每页留不出正文时的下限(mm),低于它就不再重复页眉,避免切出上百页 */
const MIN_BODY_HEIGHT_MM = 60
export interface SigningPdfInput {
/** 已渲染的文书容器(含固定页眉与签署态取值) */
element: HTMLElement
/** 文件名,不带扩展名 */
fileName: string
}
/** 量固定页眉的高度(含下外边距)——它是每页要重复的那一条 */
function measureHeaderHeight(element: HTMLElement) {
const header = element.querySelector<HTMLElement>('.sign-doc-header')
if (!header) {
return FALLBACK_HEADER_PX
}
const marginBottom = Number.parseFloat(window.getComputedStyle(header).marginBottom) || 0
const height = header.getBoundingClientRect().height + marginBottom
return height > 0 ? Math.round(height) : FALLBACK_HEADER_PX
}
export async function createSigningPdf(input: SigningPdfInput): Promise<Blob> {
// 按需加载:这两个库合计约 600KB,只在真正导出 PDF 时才拉取,
// 不让签署工作台的首屏为「偶尔用一次」的功能买单
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
])
const { element } = input
// 克隆体在目标版心下重新排版,页眉高度以克隆体为准(实时容器可能更窄,页眉会换行)
let clonedHeaderPx = 0
const canvas = await html2canvas(element, {
scale: CAPTURE_SCALE,
backgroundColor: '#ffffff',
useCORS: true,
logging: false,
onclone: (clonedDocument, clonedElement) => {
clonedElement.style.width = `${CAPTURE_WIDTH_PX}px`
clonedElement.style.height = 'auto'
clonedElement.style.maxHeight = 'none'
clonedElement.style.overflow = 'visible'
clonedElement.style.padding = '0'
clonedElement.style.margin = '0'
clonedElement.style.background = '#ffffff'
// 文书外壳(.sign-doc)在页面里是定高滚动卡片:overflow: hidden 会把超出一屏的内容裁掉,
// 纸张阴影与圆角也不该出现在 PDF 页面上,这里一并抹平
const paper = clonedElement.closest<HTMLElement>('.sign-doc')
if (paper) {
paper.style.overflow = 'visible'
paper.style.height = 'auto'
paper.style.minHeight = '0'
paper.style.boxShadow = 'none'
paper.style.borderRadius = '0'
}
const header = clonedElement.querySelector<HTMLElement>('.sign-doc-header')
if (!header) {
return
}
const marginBottom =
Number.parseFloat(
clonedDocument.defaultView?.getComputedStyle(header).marginBottom ?? '0',
) || 0
clonedHeaderPx = Math.round(header.getBoundingClientRect().height + marginBottom)
},
})
const mmPerPx = CONTENT_WIDTH_MM / canvas.width
const headerPx = clonedHeaderPx > 0 ? clonedHeaderPx : measureHeaderHeight(element)
let headerCanvasPx = Math.min(Math.max(Math.round(headerPx * CAPTURE_SCALE), 0), canvas.height)
if (CONTENT_HEIGHT_MM - headerCanvasPx * mmPerPx < MIN_BODY_HEIGHT_MM) {
headerCanvasPx = 0
}
const bodyTopPx = headerCanvasPx
const bodyTotalPx = Math.max(canvas.height - bodyTopPx, 1)
const bodyPagePx = Math.max(
Math.floor((CONTENT_HEIGHT_MM - headerCanvasPx * mmPerPx) / mmPerPx),
1,
)
const pageCount = Math.max(Math.ceil(bodyTotalPx / bodyPagePx), 1)
const pageCanvas = document.createElement('canvas')
pageCanvas.width = canvas.width
pageCanvas.height = headerCanvasPx + bodyPagePx
const context = pageCanvas.getContext('2d')
if (!context) {
throw new Error('当前浏览器不支持画布导出,无法生成 PDF')
}
const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' })
const imageHeightMm = pageCanvas.height * mmPerPx
for (let page = 0; page < pageCount; page += 1) {
context.fillStyle = '#ffffff'
context.fillRect(0, 0, pageCanvas.width, pageCanvas.height)
// 页眉:每页重复文书顶部那一条
if (headerCanvasPx > 0) {
context.drawImage(
canvas,
0,
0,
canvas.width,
headerCanvasPx,
0,
0,
canvas.width,
headerCanvasPx,
)
}
// 正文:按页连续切片,接在页眉下面
const sourceY = bodyTopPx + page * bodyPagePx
const sliceHeight = Math.min(bodyPagePx, canvas.height - sourceY)
if (sliceHeight > 0) {
context.drawImage(
canvas,
0,
sourceY,
canvas.width,
sliceHeight,
0,
headerCanvasPx,
canvas.width,
sliceHeight,
)
}
if (page > 0) {
pdf.addPage()
}
pdf.addImage(
pageCanvas.toDataURL('image/png'),
'PNG',
MARGIN_X_MM,
MARGIN_TOP_MM,
CONTENT_WIDTH_MM,
imageHeightMm,
)
pdf.setFontSize(9)
pdf.setTextColor(130, 130, 130)
pdf.text(`${page + 1} / ${pageCount}`, PAGE_WIDTH_MM / 2, PAGE_HEIGHT_MM - 7, {
align: 'center',
})
}
return pdf.output('blob')
}
/** 生成并触发浏览器下载 */
export async function downloadSigningPdf(input: SigningPdfInput): Promise<void> {
const blob = await createSigningPdf(input)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${input.fileName}.pdf`
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}