医院电子病历系统改造:TinyMCE集成PDF、签名与跨平台实践

医院电子病历系统改造:TinyMCE集成PDF、签名与跨平台实践 接到医院电子病历系统改造任务那天科室主任给我提了几个“小需求”病历里能直接插入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 RomanTimes New Roman; Noto Serif SCNoto 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 classsign-mark contenteditablefalse>editor.ui.registry.addButton(signArea, { text: 签名区, tooltip: 插入电子签名区, onAction: () { const id sign_ Date.now(); editor.insertContent(div classsign-mark contenteditablefalse>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、colspanTinyMCE解析的时候经常错位医生的原意被完全破坏。所以我们定了两条路复制粘贴的场景用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-nameSection Title] h2:fresh, p[style-nameNormal] 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不允许内联stylecolor: red。第三结构修补表格宽度超过编辑器内容宽度的统一设置max-width: 100%并允许横向滚动列表嵌套层级充其量保留两层再深就折叠为文本。第四内容校验导入后计算字数和图片数量超过合理范围就提示医生确认。安全这块尤其要重视。医院系统是内网不代表就没有风险医生从外网U盘拷贝的Word文档可能携带恶意宏或恶意链接虽然粘贴和转HTML的过程已经剥离了宏和脚本但我们还是用DOMPurify做了二次净化防止各种XSS payload漏进来。5.5 存量病历批量迁移历史Word文档怎么进新系统新系统上线前还有一个存量病历迁移问题。医院十几年的历史病历大量以Word文件形式躺在文件服务器上不可能都让医生手动复制。我们写了一个批量转换脚本用LibreOffice headless把doc/docx成批转成HTML再入库。脚本流程是先按科室和病案号扫描目录然后逐一转换转换后人工抽检因为老病历模板千奇百怪样式偏差很大。这个过程中发现最典型的问题老版本Word生成的doc文件编码混乱有些用GBK有些是繁体Big5LibreOffice转换时会出现乱码。我们的处理是先用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多出空白页页面中存在多余的分页符检查并清理stylepage-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用的是SimSunLinux服务器上映射成了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升级还是无头浏览器版本变化都能单独维护、单独测试不会牵一发动全身。这套方案在电子病历场景下已经跑了一年多稳定性和评审验收都过了关希望对正在处理类似问题的同行有用。