feat(signing): 前端生成签署后 PDF 原型
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user