249 lines
9.4 KiB
TypeScript
249 lines
9.4 KiB
TypeScript
/**
|
||
* 前端生成「签署后 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)
|
||
}
|