医院电子病历系统改造:TinyMCE集成PDF、签名与跨平台实践
2026/9/16 5:42:34 网站建设 项目流程

接到医院电子病历系统改造任务那天,科室主任给我提了几个“小需求”:病历里能直接插入PDF报告,出院小结要有医生手写签名和CA数字签名,全院乱七八糟的Windows、国产Linux和Mac上都不能出乱子,还有最关键的一条——“医生老爱从Word往里粘东西,你们管管”。最终我们把技术选型定在TinyMCE上,围绕PDF、签名、跨平台、Word导入四个关键词,前后干了一个季度,踩坑无数。这篇分享就是那次改造的完整技术复盘,内容偏实操,适合正在做医院信息化、电子病历、OA办公系统中复杂富文本场景的开发者参考。

1. 项目背景与整体设计思路

1.1 需求拆解:一个编辑器要扛起四件难啃的事

医院电子病历编辑器比普通网站编辑器复杂得多,它不是“写一篇图文混排文章”那么简单。我们拆解下来,核心需求大概四条。

第一件事是PDF。医生每天要看大量的检验报告、影像报告、外院会诊资料,很多都是PDF格式。过去这些PDF只能挂在附件区,医生想引用关键数值就得来回切换窗口,评级专家也要求“报告结果要能在病历正文里可见”。所以我们需要的不是单纯上传附件,而是把PDF内容真正揉进编辑区,让医生能翻页、能缩略、能定位到关键段落。

第二件事是签名。病历归档文件必须具备完整的签名链路:医生手写签名、科室主任审签、数字证书签名、时间戳。手写签名要采集笔迹并叠加到固定位置,数字证书要对接医院现网的CA中心,用国密SM3算摘要、SM2做签名。产出的PDF送到档案室和第三方评审那里,必须能够完成验签,任何篡改都要能被发现。

第三件事是跨平台。三甲医院的信息科永远面对一堆“历史遗留设备”:门急诊有Windows 7老机器,住院病区新采购的是国产Linux一体机,高端病房和领导办公室是MacBook,浏览器主要是Chrome和Edge,但也可能蹦出来一个IE内核的旧系统。编辑器在这三类终端上必须长得一样、打印得一样、签名流程一样,这个成本往往被很多项目低估。

第四件事是Word导入。医生写病历的习惯根深蒂固:先在Word里起草,再复制粘贴到系统,或者直接上传docx。Word生成的HTML非常脏,内联样式铺天盖地,还有一堆mso开头的私有属性。如果这些垃圾格式全部进编辑器,轻则排版乱,重则把整个病历模板撑变形,所以清洗策略必须单独设计。

这四件事单独拿出来都不是新鲜课题,但揉进同一个TinyMCE编辑器里,互相还会打架。比如PDF插入方式和Word粘贴的图片处理机制冲突;手写签名占位符在打印导出时坐标偏移;跨平台字体宽度不同导致签名位置错位。这些问题只能在实际联调中一个个喂出来。

1.2 为什么选TinyMCE:一次带着偏见的选型

我们做选型时主要对比了三个方向:CKEditor 5、wangEditor、UEditor。CKEditor 5功能强、文档全,但是自托管和离线部署的授权方式需要走商务流程,医院这边采购周期太长;wangEditor轻量、中文友好,但复杂表格操作、Word粘贴清洗、自定义签名插件这些场景明显吃力;UEditor基本停更多年,前端技术栈太旧,直接排除。

TinyMCE 6最终胜出,看中的点很清楚。第一,支持self-hosted,内网部署没有CDN依赖,这对医院这种敏感环境是刚需。第二,powerpaste插件对Word粘贴内容有专门的清洗通道,从根上解决我们最头疼的格式问题。第三,自定义按钮、自定义格式、非编辑区域的API都很成熟,适合把PDF预览和签名占位这种业务功能挂进去。第四,社区活跃,遇到问题基本都能搜到方案。

整体技术栈我们定的是:前端Vue 3 + TinyMCE 6自托管版 + pdf.js做PDF预览渲染 + Canvas采集手写签名 + 服务端Java无头浏览器渲染PDF + 院内CA服务做国密签名与时间戳。这里有个重要的设计原则:编辑器只负责内容的编辑与展示,所有需要背书和归档的动作全部放到服务端,理由我后面在签名小节详细讲。

1.3 数据链路:从编辑、预览到归档完整串一遍

先梳理完整链路,后面展开时会反复提到。医生打开病历页,TinyMCE加载病历模板,正文是HTML。需要插入PDF时,前端上传原始PDF到文件服务,获取文件ID,编辑器里插入一个自定义的PDF容器元素,容器内部用pdf.js渲染,同时生成一个首页缩略图插入正文。签名流程里,医生点击“签名”按钮,弹出手写签名板,Canvas保存笔迹为透明PNG,这张PNG关联到签名占位符。保存病历的时候,编辑器内容HTML整体提交给服务端。服务端在归档环节做三件事:把PDF容器替换成正式的打印版PDF页、把签名PNG按坐标嵌入指定区域、调用无头浏览器渲染整份HTML生成归档PDF,最后对归档PDF做SM3摘要、SM2签名、加时间戳。

这条链路上最容易被忽略的是“编辑态”和“归档态”的差异。编辑态要考虑医生的操作体验,所以PDF是可翻页的预览组件;归档态要保证版式固定、签名位置不可移动、文件可验签,所以PDF必须由服务端统一生成。如果一个方案试图让前端直接把编辑态的东西导出成正式归档文件,合规性和稳定性都会出问题。

2. TinyMCE 集成与核心配置

2.1 基础集成:自托管初始化是怎么配的

TinyMCE自托管部署并不复杂,把官方资源包下载到Nginx静态目录,前端页面里引入tinymce.min.js即可。第一次接入时要注意license_key参数,TinyMCE 6对自托管有GPL和商业授权两种模式,我们用的是GPL,在init里加上license_key: 'gpl',否则控制台一直弹授权提示。初始化参数的完整示例大致这样:

tinymce.init({ selector: '#emr-editor', license_key: 'gpl', language: 'zh_CN', height: 680, menubar: false, branding: false, plugins: 'lists table image link autosave powerpaste preview print searchreplace code fullscreen noneditable', toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | alignleft aligncenter alignright | bullist numlist | table image | insertPdf signArea | fullscreen', powerpaste_word_import: 'clean', powerpaste_html_import: 'clean', content_css: '/static/emr/emr-content.css', font_family_formats: '宋体=宋体,SimSun; 黑体=黑体,SimHei; 仿宋=仿宋,FangSong; 楷体=楷体,KaiTi; 微软雅黑=微软雅黑,Microsoft YaHei; Times New Roman=Times New Roman; Noto Serif SC=Noto Serif SC, Source Han Serif SC, simsun', valid_elements: 'p[class],span[class],strong,em,ul,ol,li,table[width|border|cellspacing|cellpadding],thead,tbody,tr,td[colspan|rowspan|width|height|class],h1,h2,h3,h4,h5,h6,hr,br,img[src|alt|title|width|height|data-pdf|class],div[class|data-sign-id],a[href|target]', paste_merge_formats: false, content_style: '@import url("/static/emr/emr-content.css");', setup: function(editor) { // 自定义按钮注册在2.3节 } });

这里有几个细节必须提醒。一是language中文包要提前下载好放到tinymce目录的langs文件夹,离线环境里不会自动去CDN拉。二是content_css这块很多项目会忽略,病历排版必须单独写一套CSS,不然默认样式会让宋体和行距完全不对。三是powerpaste_word_import我建议先用'prompt'跑一阵,让医生自己选择“保留格式”还是“纯文本”,等习惯了之后再改成'clean'。直接上'clean'的结果就是医生粘过来的表格全部变形,折腾几次就被投诉。

2.2 病历排版:在编辑器里锁定“出版社级”样式

病历是一种排版要求很高的文书,各家医院都有自己的质控要求。我们这边的要求是:文档标题黑体居中,正文宋体小四号,行距固定值22磅,段落首行缩进2字符,护理记录用仿宋,过敏史、危急值等内容要醒目标红。这些要求如果靠医生手工去设置,永远不可能统一,必须做成编辑器里的预设格式。

TinyMCE里我用了style_formats和formats两个机制来做这件事。formats注册脚本化的格式,比如“首行缩进2字符”其实是一个带text-indent的段落样式,style_formats则把它们组织成下拉菜单:

style_formats: [ { title: '病历标题', block: 'h3', classes: 'emr-title' }, { title: '正文段落', block: 'p', classes: 'emr-body' }, { title: '一级护理', block: 'p', classes: 'nursing-level-1' }, { title: '过敏史标注', inline: 'span', classes: 'allergy-tag' }, { title: '检验异常值', inline: 'span', classes: 'lab-abnormal' } ], formats: { indent2: { block: 'p', styles: { 'text-indent': '2em' } }, lineHeight22: { block: 'p', styles: { 'line-height': '22pt' } } }

对应在emr-content.css里写清楚这些类的样式,并加上!important防止被TinyMCE默认CSS覆盖。病历内容最终是法律文书,所以样式上宁可死板也不要花哨。还有一点很关键:我们在valid_elements里限制得很细,像font标签、center标签、background属性这类老式HTML一律过滤掉,这样即使医生从网上复制一段乱糟糟的内容,至少标签层面不会太离谱。

2.3 自定义按钮:把PDF插入和签名占位做成工具栏能力

TinyMCE的工具栏扩展非常灵活,我们通过setup回调里的editor.ui.registry.addButton注册了两个核心按钮:insertPdf和signArea。insertPdf的逻辑是:点击后弹窗选择PDF文件,前端把文件上传到文件服务,拿到文件ID后,向编辑器里插入一段自定义的PDF容器HTML。考虑到医生需要在正文中看到PDF的存在,我们设计成双形态:正文里先放一个首页缩略图(后端PDF转图片接口即时生成),点击缩略图则展开成完整的pdf.js预览面板,外层的div加了noneditable类,防止医生误删内部iframe。

signArea按钮则是插入签名占位符的入口,插入的HTML大概这样:

<div class="sign-mark" contenteditable="false">editor.ui.registry.addButton('signArea', { text: '签名区', tooltip: '插入电子签名区', onAction: () => { const id = 'sign_' + Date.now(); editor.insertContent(`<div class="sign-mark" contenteditable="false">pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdfjs/pdf.worker.min.js';

如果不配置,部分浏览器会尝试跨域加载,直接白屏。第二个是中文字体资源,CMap文件要放到本地并通过CmapUrl指定,否则遇到带特定编码的中文PDF会乱码:

const pdfTask = pdfjsLib.getDocument({ url: pdfUrl, cMapUrl: '/static/pdfjs/cmaps/', cMapPacked: true, standardFontDataUrl: '/static/pdfjs/standard_fonts/' });

离线内网环境尤其要注意这两项,CDN不可用的情况下很多在线示例根本跑不起来。另外,扫描版PDF通常自带歪斜和黑边,直接转缩略图很难看,我们在后端的图片处理节点里加了纠偏和对比度增强,至少保证首页缩略图是清晰的。

3.2 从HTML到归档PDF:三条路线,为什么最后选了服务端渲染

病历编辑完成后要导出正式的PDF归档文件,我们前后试了三条路线。

第一条是前端截图方案:html2canvas把DOM截成图片,再用jspdf把图片分包放进PDF。这条路线部署简单,但实际用下来问题非常明显。病历动辄十几页甚至几十页,html2canvas对大DOM的渲染时间极慢,内存占用高,低配的国产Linux终端直接卡死。而且它产出的是图片型PDF,文字不能选中、检索不了,档案室做全文检索时直接傻眼。

第二条是浏览器自带的window.print()加打印CSS。所见即所得,分页自然,这是它的优点。但缺点也致命:打印对话框的行为每个浏览器不一样,服务端无法统一控制;医生在病区用的浏览器如果版本不对,打印出来的页眉页脚、签名位置就会乱。关键是归档文件要被CA签名和验签,由前端生成的PDF在审计上存在“内容可被篡改”的争议,合规性不足。

第三条是我们最终采用的服务端无头浏览器渲染:医生点“归档”按钮后,前端把编辑器的HTML原样提交给Java后端,后端把HTML拼接到一个排版模板页面中,用Playwright控制Chrome无头模式加载这个页面,设置好A4尺寸、页边距、页眉页脚,调用page.pdf输出标准PDF,然后再走签名服务。这条路线的优势是稳定、可控、可批量,输出的PDF是文字型PDF,支持检索和复制,跨平台一致性也有保障——因为渲染环境固定,不依赖医生终端。

无头浏览器的PDF模板里,我们特别处理了三个点。第一,页面CSS里强制A4页面大小并设置margin,不然打印时默认边距会导致内容溢出。第二,用page-break-inside: avoid防止表格或者签名块被拦腰截断。第三,页眉页脚通过模板加入病案号和页码,这些信息在终稿PDF里要有。

3.3 手写签名 + CA数字签名:签名链路是怎么落地的

签名是整个项目中合规要求最高的一块。先说手写签名。医生在病区的签名方式有几种:触摸屏一体机直接手写、外接手写板、以及平板移动查房。前端统一用Canvas采集笔迹,写一个采集组件,要处理的关键点包括:笔迹轨迹点按时间序列采样,不能用鼠标的move事件随便画;连续笔画之间要做防抖,不然快速书写时会出现断线,医生签名识别率低的投诉基本都来自这里;采样完成后把Canvas导出为透明背景的PNG,宽度和高度要跟签名区预设尺寸匹配,否则后续嵌入PDF时会拉伸变形。

签名PNG上传后,前端会更新之前那个data-sign-id对应的占位符div,把等待文字替换成签名图片。编辑状态看到的签名图是一张普通PNG,但在归档生成PDF时,服务端会重新读取签名PNG文件,按照占位符在页面中的绝对坐标和尺寸,用PDF编辑库把它精确绘制到PDF页面上。这里又有一个关键设计:为什么签名不在HTML渲染阶段就直接显示,而要在生成PDF后二次绘制?因为HTML渲染里的图片本质上是位图,缩放、换行都会导致相对位置变化,而归档PDF一旦生成就不允许再变。签名必须作为独立图层叠加在内容之上,这样后续验签时才能通过哈希判断页面内容有没有被改动。

CA数字签名我们对接的是医院现网常用的CA签名服务,算法采用国密SM3摘要加SM2签名。流程是服务端拿到待归档的PDF字节流,先计算整个PDF的摘要,调用CA签章服务器对摘要做签名,再从时间戳服务器获取标准时间戳,最后把签名值、证书链、时间戳一起封装成一个PKCS#7签名字段嵌入PDF。这个流程用代码来表达大致是:

async function signArchivePdf(pdfBuffer: Uint8Array, certId: string, userId: string) { const digest = sm3(pdfBuffer); const signValue = await caServer.sign(certId, userId, digest); const tsToken = await timestampServer.getToken(signValue); return embedPkcs7Signature(pdfBuffer, signValue, tsToken); }

嵌入签名后的PDF,任何试图修改页面内容的行为都会导致签名校验失败,这就在技术层面保证了电子病历归档文件的完整性和不可否认性。CA签名加时间戳也是评审验收时档案部门重点核对的内容,这块不能省。

另外还要提醒一点:CA签名不是把所有PDF都无脑签一遍。一份病历里通常有多个签名节点,比如住院医师、主治医师、科主任,每个节点都要进行独立的签名操作,生成的是同一份PDF上多个签名域。我们在归档流程里用状态机管理签名流程,一个节点签完并锁定后,下一个节点才允许继续操作。这块逻辑虽然不在TinyMCE里,但整条链路缺一环都不行。

3.4 PDF中文乱码与字体嵌入

PDF乱码这个坑藏得很深。开发环境都是Windows,字体全,渲染出来的PDF一切正常。一上国产Linux服务器,无头浏览器渲染的时候找不到宋体,PDF里中文全部变成方块或者被替换成默认的楷体,版式全乱。后来我们统一在渲染环境里安装了思源宋体Noto Serif CJK SC,并通过fontconfig做了字体别名映射,把CSS中的SimSun、宋体都指到Noto Serif CJK SC上。

如果是前端转图方案,字体问题也会体现在Canvas绘制中文上,Canvas默认字体和浏览器默认字体如果不匹配,画出来的PDF图片同样乱码。所以我们最终放弃前端方案也有这个原因。归档PDF要求字体嵌入,避免换台电脑打开就乱码,无头浏览器渲染PDF时默认会嵌入所用字体子集,这一步省了我们不少事,但前提是渲染服务器上必须把字体装齐,且CSS里不要写浏览器完全不认识的字体名。

4. 跨平台兼容:浏览器与操作系统的较量

4.1 终端现状盘点:先列一张环境判断表

医院终端千奇百怪,为了不吵架,我们做了一张对照表,把目标环境固定下来。Windows阵营是绝对主力:Win10教育版和Win7专业版大量共存,浏览器以Chrome和Edge为主,极少数老电脑还装了360安全浏览器和搜狗浏览器。国产化阵营这几年越来越多:银河麒麟、统信UOS都有,自带的浏览器基本是Chromium内核套壳。macOS主要在行政办公和部分高端病区,浏览器以Safari和Chrome为主。

操作系统主要浏览器打印方式重点关注
Windows 10 / 7Chrome、Edge系统打印服务老电脑性能瓶颈
银河麒麟 / 统信UOS内置Chromium套壳浏览器系统打印服务字体缺失、内核版本老
macOSSafari、ChromeAirPrint/系统打印默认字体差异大
iOS / Android 平板Safari / Chrome无线打印触摸签名兼容

每类环境都要有固定的测试负责人,上线前至少跑两轮完整回归。这里的教训是:不要被“都支持Chrome内核”蒙蔽,国产Linux一体机自带的浏览器很多是多年前的Chromium 70甚至60,代码里一个可选链操作符?.就能让整页白屏。

4.2 字体与打印差异:同一个页面,三个平台三种脸

跨平台最直观的问题就是字体。Windows的宋体SimSun、黑体SimHei是系统自带;macOS没有SimSun,但有宋体-简、华文宋体,字体名完全不同;Linux更干脆,默认没有商业中文字体。如果不做处理,同一份病历在Windows上显示正常,在macOS上自动变成苹方或者宋体-简,到了Linux直接落到默认的Noto Sans CJK,字距行距全变。

我们的处理方案是统一字体栈,CSS里写成:

body { font-family: "Noto Serif SC", "Source Han Serif SC", "SimSun", "宋体", serif; }

然后在每台终端上尽量部署Noto Serif SC,或者用fontconfig做别名路由。这样至少保证显示效果接近。打印方面,医生经常打印纸质归档件,打印兼容要特别处理。首先是分页,表格行不能跨页截断,必须给tr加page-break-inside: avoid;其次是颜色,Chrome默认不打印背景色,要在打印CSS里加:

* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }

否则病历里那些标红的异常值打印出来全变成黑色,质控部门会来找你。

4.3 上传、Canvas、File API 的兼容处理

医院内网环境里有很多老版本浏览器,FileReader、FormData、Blob这些基础API在新浏览器上没问题,但在老版国产浏览器上偶发不兼容。我们在代码里做了一层降级:检测window.FileReader是否存在,不存在就走form表单隐藏iframe上传;图片预览不用URL.createObjectURL,改成FileReader.readAsDataURL的兼容写法;canvas导出用toBlob,不支持就退化为toDataURL。

这里有个特别坑的细节:国产Linux一体机自带浏览器经常是32位的老内核,WebGL都不一定能用。TinyMCE本身不依赖WebGL,但pdf.js在部分版本会尝试用WebAssembly加速,加载失败就会回退到纯JS模式,速度慢一倍,功能倒是能用。如果发现某些PDF页面渲染不出来,先关掉wasm再测。

4.4 内网离线部署:断网环境下怎么把整套东西跑起来

医院的内网环境通常与外网物理隔离,所有依赖都必须提前下载好。TinyMCE资源包、中文语言包、pdf.js的构建产物、字体图标,全部要落到本地静态资源目录,并通过Nginx提供服务。我们当时的静态资源目录结构大致是:

/opt/emr/static/ ├── tinymce/ │ ├── tinymce.min.js │ ├── langs/zh_CN.js │ └── plugins/ ├── pdfjs/ │ ├── pdf.min.js │ ├── pdf.worker.min.js │ ├── cmaps/ │ └── standard_fonts/ └── emr/ ├── emr-content.css └── vendor/

Nginx上对这两个路径做了独立配置,重点是要处理好pdf.js worker的跨域读取。如果pdf.js资源和业务页面不在同一个域,或者走不同端口,pdf.js加载的时候会报跨域错误。直接在服务器配置里加CORS头是最省事的方式:

location /static/pdfjs/ { alias /opt/emr/static/pdfjs/; add_header Access-Control-Allow-Origin *; }

还要注意一个Content-Security-Policy的坑。医院的安全策略可能会往页面上加CSP头,如果CSP里写了script-src 'self',那么pdf.js的worker脚本就加载不了,因为worker需要单独的worker-src或blob:允许。我们最后在Nginx响应头里显式放行了相关脚本源,并给pdf.worker.min.js开了worker-src 'self',才解决问题。

5. Word 导入:把医生从格式泥潭里拉出来

5.1 医生为什么总是粘Word,以及脏HTML有多可怕

医生用Word写病历的习惯是历史形成的,短期内不可能改。直接复制粘贴到富文本编辑器,问题一大堆。我见过最夸张的一次,一段三行文字,HTML源码里带着两百多行内联CSS,还有各种mso-开头的私有属性,什么mso-bidi-font-family、mso-fareast-font-family,渲染出来反而乱套。更麻烦的是表格,Word里的合并单元格在HTML里是一堆rowspan、colspan,TinyMCE解析的时候经常错位,医生的原意被完全破坏。

所以我们定了两条路:复制粘贴的场景用powerpaste的清洗能力;上传docx文件的场景用mammoth.js做结构化解析。两条路最终都通向“再清洗→统一为病历HTML规范”这个终点。这里也顺带说一句,有些医生习惯先用PDF转Word工具把电子版报告转成docx再粘进来,这种工具生成的文件结构往往更差,我们一般建议直接上传原始docx或者复制原文,不要走二次转换。

5.2 powerpaste插件:粘贴清洗的黄金选项

powerpaste是TinyMCE官方的高级插件,我们主要用它来处理从Word、WPS复制粘贴过来的内容。核心配置是:

powerpaste_word_import: 'clean', powerpaste_html_import: 'clean',

'clean'模式会把Word自带的一大堆样式剥离,只保留基础的段落、粗体、斜体、列表结构和表格。但这里要小心,clean模式有时候会把医生刻意调的表格宽度、内容缩进也清掉,所以我们又配合valid_elements做了一层白名单,确保清洗后剩下的标签都在可控范围内。粘贴图片要单独处理,powerpaste会把剪贴板里的图片转成base64,如果不处理直接塞进编辑器,几张大图就能让编辑器卡死。我们的做法是监听paste事件,拦截base64图片,转成File对象上传到文件服务,把src替换成上传后的URL。

editor.on('PastePreProcess', function(e) { e.content = normalizePastedContent(e.content); });

这里还有个经验:WPS粘贴过来的html比Word还要乱,powerpaste对WPS的支持不如Word,所以产品层面我们给医生统一了提示:“优先使用上传docx的方式,或者从系统自带的模板新建”。

5.3 上传docx解析:mammoth.js把Word转成干净HTML

当医生选择上传一个docx文件时,我们不走粘贴通道,而是用mammoth.js在前端直接解析。mammoth的convertToHtml方法可以把docx转成语义化HTML,默认输出比Word的粘贴HTML干净得多。代码示例如下:

const result = await mammoth.convertToHtml({ arrayBuffer: buffer }, { convertImage: mammoth.images.imgElement(async (image) => { const imageBuffer = await image.read(); const blob = new Blob([imageBuffer], { type: image.contentType }); const uploadedUrl = await uploadToOss(blob); return { src: uploadedUrl }; }), styleMap: [ "p[style-name='Section Title'] => h2:fresh", "p[style-name='Normal'] => p:fresh" ] }); const cleanHtml = sanitizeHtml(result.value); editor.insertContent(cleanHtml);

这里convertImage的回调很关键。docx里的图片体积可能很大,直接内联base64不仅会塞爆编辑器内容,还会拖慢保存接口。我们把图片提取出来上传到文件服务,把src改成URL,既保证文档完整,又控制了体积。

mammoth也不是万能的,它对复杂的文本框、艺术字、SmartArt这些Word高级特性支持很差,有时候会变成乱码或者直接丢弃。针对这种情况,我们的策略是:如果docx解析后某个段落的内容异常少,就提示医生“该段落包含复杂排版对象,请手动校准”。病历这种文书内容,宁可让医生多花三十秒修一下,也不能带着错误格式归档。

5.4 导入后的格式清洗与安全过滤

不管是粘贴还是上传,导入之后都要过一遍统一清洗函数。清洗函数做几件事。第一,标签过滤:去掉script、iframe、object、embed、link这些危险或无用标签,去掉onclick、onerror这类事件属性。第二,样式规范化:把所有颜色、字体、字号统一到预设的几个类上,比如异常值标红只允许span.lab-abnormal,不允许内联style="color: red"。第三,结构修补:表格宽度超过编辑器内容宽度的,统一设置max-width: 100%并允许横向滚动;列表嵌套层级充其量保留两层,再深就折叠为文本。第四,内容校验:导入后计算字数和图片数量,超过合理范围就提示医生确认。

安全这块尤其要重视。医院系统是内网,不代表就没有风险,医生从外网U盘拷贝的Word文档可能携带恶意宏或恶意链接,虽然粘贴和转HTML的过程已经剥离了宏和脚本,但我们还是用DOMPurify做了二次净化,防止各种XSS payload漏进来。

5.5 存量病历批量迁移:历史Word文档怎么进新系统

新系统上线前还有一个存量病历迁移问题。医院十几年的历史病历大量以Word文件形式躺在文件服务器上,不可能都让医生手动复制。我们写了一个批量转换脚本,用LibreOffice headless把doc/docx成批转成HTML再入库。脚本流程是先按科室和病案号扫描目录,然后逐一转换,转换后人工抽检,因为老病历模板千奇百怪,样式偏差很大。

这个过程中发现最典型的问题:老版本Word生成的doc文件编码混乱,有些用GBK,有些是繁体Big5,LibreOffice转换时会出现乱码。我们的处理是先用file命令检测编码,再指定输入编码重新转换,实在不行的进入人工修复队列。批量迁移这件事看起来和技术关系不大,但它是整个系统上线是否能通过验收的关键,建议在项目计划里留出时间。

6. 常见问题排查与经验速查

6.1 高频问题速查表

我把项目里遇到的高频问题整理成一张表,方便同行直接对照。

问题现象常见原因解决方案
插入PDF后编辑器里是空白pdf.js worker路径或跨域配置不对显式设置GlobalWorkerOptions.workerSrc,检查Nginx的CORS头
PDF中文显示乱码CMap字体资源没本地化配置cMapUrl和standardFontDataUrl,指向本地cmaps目录
打印时签名图位置漂移占位符div与最终签名图尺寸不一致生成PDF时根据data-sign-id定位,固定width和height
宋体在国产Linux终端显示成方块服务器或终端缺SimSun字体安装Noto Serif CJK SC,配置fontconfig别名
从WPS粘过来的表格全乱WPS HTML结构特殊,powerpaste处理有限提示医生上传docx,或用粘贴为纯文本再格式化
粘贴的图片显示红叉base64图片过大或上传接口超时压缩后上传到文件服务,替换src为URL
大文档编辑越来越卡编辑器内图片全部是base64图片一律转URL存储,避免超长HTML
导出PDF多出空白页页面中存在多余的分页符检查并清理style="page-break-after: always"残留
CA签名后的PDF某些阅读器打不开PKCS#7封装不规范使用供应商SDK生成签名域,不要手写PDF对象
Chrome打印时标红颜色变黑默认不打印背景色添加-webkit-print-color-adjust: exact
国产Linux终端白屏内核版本过老不支持现代JS语法代码转ES5,或更新浏览器到Chromium 90+
上传大PDF超时前端直接把原始文件推到后端前端转缩略图后压缩上传,原始文件走异步大文件通道

6.2 一次典型的跨平台排障:签名偏移问题全过程

挑一次印象深刻的排障过程详细说说,也许能帮你省几个小时。问题表现是:Windows终端上生成的归档PDF签名位置完全正确,但Linux服务器上生成的PDF签名整体向右上角偏移了大约15像素。

一开始怀疑是签名坐标计算错误,但同一个签名模块在Windows上没问题,说明坐标逻辑没问题。后来怀疑是CSS布局差异,我们把Linux服务器渲染的PDF和Windows渲染的对比,发现正文中某些字体的宽度不同,导致同一段文字在Linux上换行位置不一样,整个版面下移。特别是中文字体,Windows用的是SimSun,Linux服务器上映射成了Noto Serif CJK SC,同一个字符的宽度有细微差异,几十行文字累积下来就产生了明显的位移。

解决思路是锁定渲染环境,强制归档模板只用一套字体,并且把字体的渲染结果做成基准测试页面。我们在部署脚本里加了一步:无头浏览器启动后先渲染一个包含常见汉字的测试页,把渲染结果截图存档。每次升级服务器或替换字体后先跑一次基准测试,确保归档PDF的版式基线不变。这个测试页后来也成了我们排查其他跨平台显示问题的标准工具。

6.3 排障方法论:先锁定终端环境再谈代码

排障经验里最深的一条感悟:跨平台问题里,80%的Bug是环境差异引起的,不是代码逻辑问题。遇到问题先确认终端浏览器内核版本、操作系统、字体版本,再去看代码。我们为此做了一个“环境诊断页”,嵌入在系统首页的隐藏路由里,一键显示浏览器UA、内核版本、是否支持WebGL、是否支持ES2020、系统字体列表等。反馈问题时让医生把这个诊断页的截图发过来,效率翻倍。

还有一个习惯养成很重要:所有跨平台改动都要配套截图留档。每次改完CSS、换完字体、升级完pdf.js,就对三套终端环境分别截图存档。这些截图不光是排障依据,也是和医院信息科对接时最直观的验收材料。

6.4 联调测试清单

项目上线前,我们整理了一份跨平台联调测试清单,核心检查项如下:

  • Windows 10 Chrome/Edge:基础编辑、PDF预览、签名、导出、打印
  • Windows 7 360安全浏览器:基础编辑、Word粘贴、签名、导出
  • 银河麒麟自带浏览器:基础编辑、PDF中文显示、字体渲染、打印
  • 统信UOS Edge:表格编辑、PDF容器展开、CA签名
  • macOS Safari:富文本编辑、图片上传、打印样式
  • 无头渲染服务器:字体基准测试、PDF版式、签名坐标

每一轮测试都要保留当时的产物,特别是导出PDF和截图,这样出了问题可以对比不同版本之间的变化。

这个项目做完之后,我最大的体会是,TinyMCE只是我们整个电子病历文档生产线的一小部分,真正难的是把编辑、PDF、签名、跨平台、Word导入这些环节串起来,并且让它们在一个严格受控的内网环境里稳定运行。如果现在让我重新做一遍,我会把精力更多放在服务端PDF渲染和签名链路的自动化测试上,因为这两块是医院最在意、也最不能出错的部分。最后再分享一个小技巧:所有跟打印、导出、签名有关的模板页面,一定要做成独立的HTML页面而不是嵌在业务系统里,这样不管是TinyMCE升级还是无头浏览器版本变化,都能单独维护、单独测试,不会牵一发动全身。这套方案在电子病历场景下已经跑了一年多,稳定性和评审验收都过了关,希望对正在处理类似问题的同行有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询