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