Files
xh-medical-sign-web/clinical-web/src/utils/signing-pdf.ts
T

249 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 前端生成「签署后 PDF」(原型阶段)。
*
* 真实实现里 PDF 由服务端生成;这里用 html2canvas 把已经渲染好的文书截图,
* 再按 A4 分页拼进 jsPDF,让前端能先看到成品效果:
* ① 截图用固定版心宽度:实时容器有固定高度、内边距,且宽度随工作台面板变化,
* 直接截图既截不全、版面也会跟着变;
* ② 页码只写 "1 / 2" 这类 ASCII:jsPDF 内置字体不含中文,
* 画中文页码需要内嵌字体(体积会明显变大),这一步留给服务端用真字体实现;
* ③ 「只读副本」的水印在克隆体上平铺(injectWatermark):屏幕上那份文书不出现水印,
* 只有导出的 PDF 带;水印烙进截图,每一页都有,且改不掉文本层。
*
* **每页只有正文,没有页眉**(2026-09-30 去掉):原先会在每页顶部重复文书那条
* 系统页眉(患者姓名 / 性别 / 年龄 / 住院号),而编辑器里根本没有它 ——
* 编辑时看不到、签出来和下载的 PDF 上却多一条,是"渲染端自己长出来的"东西。
* 患者信息要出现在文书上,就在正文里插对应的占位符。
*
* 输出为位图 PDF:文本不可选中,仅供原型演示;正式文书以服务端生成的 PDF 为准。
*/
import { A4_CONTENT_WIDTH_MM, A4_CONTENT_WIDTH_PX, A4_PAPER_MM } from './document-paper'
const PAGE_WIDTH_MM = A4_PAPER_MM.width
const PAGE_HEIGHT_MM = A4_PAPER_MM.height
/**
* 页边距与屏幕上的纸**同一份口径**(`utils/document-paper.ts` 的 `A4_PAPER_MM`)。
*
* ⚠️ 这里曾经自己写死 15 / 12 / 14mm,与屏幕的 31.75 / 14.34mm 对不上 ——
* 表现为「下载的 PDF 和预览的边距不一样」,而且版心宽度也差 33mm、换行位置跟着变。
* **不要再在这里写 mm 数字**,要调就调 `A4_PAPER_MM`。
*/
const MARGIN_X_MM = A4_PAPER_MM.marginX
const MARGIN_TOP_MM = A4_PAPER_MM.marginY
const MARGIN_BOTTOM_MM = A4_PAPER_MM.marginY
const CONTENT_WIDTH_MM = A4_CONTENT_WIDTH_MM
const CONTENT_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_TOP_MM - MARGIN_BOTTOM_MM
/**
* 截图版心宽度(px)= 屏幕上纸的版心宽度(`A4_CONTENT_WIDTH_PX`)。
*
* 为什么必须是这个值而不是「约 180mm @96dpi」:截图时把克隆体宽度钉成它,
* 正文的换行位置才与屏幕完全一致(都是同一个版心宽);
* 再按 `CONTENT_WIDTH_MM / canvas.width` 缩放铺进 PDF,物理尺寸也就对了。
*/
const CAPTURE_WIDTH_PX = A4_CONTENT_WIDTH_PX
/** 截图倍率:2 倍下正文与小字都还清晰 */
const CAPTURE_SCALE = 2
export interface SigningPdfInput {
/** 已渲染的文书容器(含签署态取值) */
element: HTMLElement
/** 文件名,不带扩展名 */
fileName: string
/**
* 只读副本水印文案。
*
* 只有「只读副本」这类对外提供、不可作为正式凭证的副本才传;
* 正式文书(PDF 原件)不加水印,传空即原样导出。
* 水印直接烙进截图,因此每一页都会带上,且无法通过编辑 PDF 文本层去掉。
*/
watermark?: string
}
/** 水印平铺的列数:5 列按 20% 步进,再左移 10% 让斜排文字盖住左右两侧 */
const WATERMARK_COLUMNS = 5
/** 水印行距(px):比文字高度大,斜排后不重叠 */
const WATERMARK_ROW_GAP_PX = 90
/**
* 往克隆体里平铺斜向水印。
*
* 必须在 `onclone` 里做:屏幕上那份文书不能出现水印,
* 而 html2canvas 只截克隆体,所以改克隆体既不影响界面也能进 PDF。
*
* 行数按克隆体的实际高度算(`scrollHeight` 会强制一次重排,这里正是需要的时机),
* 写死行数会让长文书的下半部分没有水印。
*/
function injectWatermark(root: HTMLElement, text: string) {
const height = Math.max(root.scrollHeight, root.clientHeight, 1)
const rows = Math.ceil(height / WATERMARK_ROW_GAP_PX) + 2
// 绝对定位的水印层要有定位祖先;容器自身多半是 static,
// 这里只改克隆体,屏幕上的版式不受影响
root.style.position = 'relative'
const overlay = document.createElement('div')
overlay.style.cssText = [
'position:absolute',
'inset:0',
'z-index:9',
'overflow:hidden',
'pointer-events:none',
].join(';')
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < WATERMARK_COLUMNS; column += 1) {
const item = document.createElement('span')
item.textContent = text
item.style.cssText = [
'position:absolute',
'display:block',
'width:280px',
'color:rgba(14,110,140,0.13)',
'font-size:14px',
'font-weight:700',
'letter-spacing:2px',
'white-space:nowrap',
`left:${column * 20 - 10}%`,
`top:${row * WATERMARK_ROW_GAP_PX + 30}px`,
'transform:rotate(-28deg)',
'transform-origin:left top',
].join(';')
overlay.appendChild(item)
}
}
root.appendChild(overlay)
}
/**
* ⚠️ html2canvas **不实现 flex 布局**:纸里若出现 flex 容器,容器内的文字、输入框取值
* 与签名 `<img>` 会被整块丢掉(实测 html2canvas 1.4:页面上明明印着「与患者关系:配偶」、
* 签名图也画出来了,导出的位图里却只剩胶囊的边框和底色)。
*
* 所以能在纸上用 block / inline-block 表达的排版就别用 flex —— 填写项胶囊(`.docfld`)
* 正是按这个口径落成 inline-block 的(见 styles/signing-document.css)。
* 历史上系统页眉是 flex 排的一行患者信息,导出前要在克隆体上降级成 block + inline-block、
* 再用 margin 补回间距;页眉已在 2026-09-30 去掉,那段降级代码一并删了。
* 若以后又要在纸上排 flex,记得把同样的降级加回 `onclone` 里。
*/
export async function createSigningPdf(input: SigningPdfInput): Promise<Blob> {
// 按需加载:这两个库合计约 600KB,只在真正导出 PDF 时才拉取,
// 不让签署工作台的首屏为「偶尔用一次」的功能买单
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
])
const { element } = input
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'
// 水印是绝对定位层,不参与布局,注入前后都不影响分页切片的位置
if (input.watermark) {
injectWatermark(clonedElement, input.watermark)
}
// 文书外壳(.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 mmPerPx = CONTENT_WIDTH_MM / canvas.width
// 一页的版心高(px):正文按它连续切片,第 N 页从 (N-1) × 这个值开始
const bodyPagePx = Math.max(Math.floor(CONTENT_HEIGHT_MM / mmPerPx), 1)
const pageCount = Math.max(Math.ceil(canvas.height / bodyPagePx), 1)
const pageCanvas = document.createElement('canvas')
pageCanvas.width = canvas.width
pageCanvas.height = 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)
// 正文:从上一页的断点接着切,整页铺满版心(页眉已去掉,不再为它留位置)
const sourceY = page * bodyPagePx
const sliceHeight = Math.min(bodyPagePx, canvas.height - sourceY)
if (sliceHeight > 0) {
context.drawImage(
canvas,
0,
sourceY,
canvas.width,
sliceHeight,
0,
0,
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)
}