/**
* 前端生成「签署后 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 容器,容器内的文字、输入框取值
* 与签名 `
` 会被整块丢掉(实测 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 {
// 按需加载:这两个库合计约 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('.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 {
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)
}