☰
CKEditor 4粘贴Word图片自动上传:HIS病历系统完整方案
2026/10/5 3:54:31 网站建设 项目流程

昨天实施群里又有人喊:医生从Word里复制一大段带图片的病历,粘贴到医院HIS系统的CKEditor编辑器里,图片全变红叉,问“示例在哪”。这个问题我前前后后回答过很多遍,每次都能从提问者的语气里听出同一个困惑:网上翻遍了,找到的要么是官方文档翻译,要么是只贴半段代码的博客,拿回来根本跑不通。

表面看是在找“CKEditor粘贴Word图片的示例”,但真正缺的不是某个函数,而是搞明白从Word到剪贴板、再从剪贴板到CKEditor中间到底发生了什么,以及图片从编辑器里冒出来之后,后台怎么接住它、怎么存进病历。这篇就按我实际改HIS前端时的一套流程来写:先讲清楚原理,再给一段可以直接抄的CKEditor 4示例,然后把医院环境里的鉴权、附件表、打印兼容这些坑全摆出来。HIS实施工程师、驻场开发、做医疗文档系统的程序员,应该都能在里面捞到点东西。

1. Word粘贴病历图片,为什么总要“变红叉”

很多人第一反应是CKEditor配置问题,改几下就能好。但把问题拆开看,真正的原因通常藏在剪贴板的内容构成里。

1.1 剪贴板里到底塞了什么

当医生在Word里选中一段带图片的病历并按Ctrl+C时,Windows剪贴板并不是只放了一份数据。它同时塞进了多种格式:纯文本、带Word样式的HTML片段、可能还有一张位图版本。浏览器拿到手之后,优先用HTML片段来渲染,因为只有它才能保留图片位置和大部分排版。

于是问题来了:这个HTML片段里的图片引用,在复制粘贴时会有几种不同的表现形态。

第一种是file:///开头指向本地临时目录,比如C:/Users/Administrator/AppData/Local/Temp/xxx.png。这种引用在网页里就是典型的“红叉”,因为浏览器出于安全限制,不允许网页脚本去读取本地文件内容,即便这个文件是刚刚由Office生成的临时文件也不行。

第二种是data:image/png;base64,xxxxx。这种形式把图片二进制直接编码成了字符串,只要不出格就能在网页上显示出来,但它也有自己的麻烦——字符串长度巨大,一张几MB的CT图片转成base64后会让HTML膨胀到原文的四五倍,直接塞进数据库会把记录撑得很难看,检索、备份、迁移都受影响。

第三种是老版本Word产物,常见于VML的<v:shape>和<v:imagedata>标签里。这些标签是十几年前微软为了兼容IE定义的矢量标记语言,现代浏览器根本不把它当正常图片渲染。CKEditor虽然提供了pastefromword插件去清理这类标签,但清理完之后图片到底能不能显示,还是取决于底层的src引用是不是真实可读。

1.2 官方示例在哪,它到底解决了什么

这个问题本身没有标准答案,因为“示例”分好几类。CKEditor官方发行包里确实带了Paste from Word的示例页面,解压后在samples目录下能找到,展示的是把Word过来的HTML样式过滤成相对干净的内容。不少实施工程师找到这个示例,兴奋地搬过去试,结果发现Word表格边框能用了,图片却还是破的,于是继续困惑。

原因在于:官方示例解决的问题是“从Word粘贴的样式清理”,不是“图片上传”。CKEditor作为一个编辑器,它只负责在你编辑内容时把图显示出来,至于图最终存到哪个服务器、以什么URL引用,那是CMS、OA、HIS这类业务系统自己的事情。所以官方文档里只会教你怎么配filebrowserUploadUrl,让你手动通过工具栏图片按钮上传,不会给你做“粘贴即自动上传”。这个差异一定要先认清。

网上确实能搜到不少“CKEditor粘贴图片自动上传”的第三方示例,但它们大多是为个人博客或后台管理系统写的,套路通常是:监听paste事件,把base64变成Blob,POST到一个通用上传接口,然后返回URL替换。这类示例最大的问题是什么?是它们默认后端接口不需要鉴权、默认上传的文件不需要和病历文书关联、默认图片存哪里都无所谓。把这些代码直接丢进HIS环境,轻则接口报401,重则出现任意账号都能往病历附件目录塞文件的严重漏洞。

1.3 你到底需要的是哪一类“示例”

想清楚需求再动手,能省一半时间。

  • 如果只是想“把文字粘进来,图片不显示也行”,那官方示例都不用看,关掉粘贴图片功能即可。
  • 如果要求“医生粘贴的图片能变成服务器文件并在编辑器里显示”,最简单的示例就是网上一堆通用后台的写法,改一下接口路径就能用。
  • 如果要求“图片上传后写入病历附件表、和当前文书绑定、打印时还能带得出来”,那就不只是示例问题,还需要设计上传接口的入参、返回结构、权限校验和存储约定。医院场景下绝大多数真实需求属于这一类。

接下来的内容,我按第二类和第三类的交集来展开:核心粘贴逻辑可以独立复现,附件表和鉴权做成最小可用方案,方便你接进自己的HIS。

2. 三条实现路线,选哪条不后悔

CKEditor 4做粘贴图片处理,大方向上无非三条路。每条路都有适用场景,也都有各自的坑,我分别拆开讲。

2.1 路线A:拦截粘贴内容,改造HTML后再插入

核心思路是监听编辑器的paste事件,在CKEditor把HTML插入页面之前,先拦下来做一次“手术”:用正则或DOM解析找出所有base64图片,异步上传,等所有文件URL回来后,把编辑后的HTML重新交给编辑器插入。

这条路线最大的好处是你对最终插入的内容有完全控制权,插入到编辑器里的HTML干净、可预测,后端存库时不用额外清洗一大坨base64。缺点是异步处理需要处理好时序,如果一张粘贴内容里有五六张图片,每一张都要发一个请求,当全部请求返回后才能插入,用户会感觉有一小段等待时间,而且中途如果用户切走了光标,插入位置可能不太对。

代码骨架大致长这样:

editor.on('paste', function (evt) { var html = evt.data.dataValue; // 没有base64图片就不干预 if (html.indexOf('data:image') === -1) { return; } evt.cancel(); // 提取所有base64内容,逐张上传后替换 // ... uploadImages(images).then(function (urlMap) { var newHtml = replaceBase64WithUrl(html, urlMap); editor.insertHtml(newHtml); }); });

这条路线也是我下面完整示例采用的主方案。它不需要等编辑器DOM渲染完再去摸节点,逻辑上更直白,排查问题也容易。唯一的代价是要理解paste事件里evt.data.dataValue的含义,以及evt.cancel()会让默认插入失效这件事。

2.2 路线B:粘贴后遍历编辑器DOM,把本地图换掉

有人不喜欢拦paste事件,怕把剪贴板里的复杂格式搞坏,于是选了另一条路:先让CKEditor按照默认方式把内容粘进去,然后监听afterPaste,等编辑器渲染完,用editor.document.find('img')遍历所有图片节点,凡是src以data:或file:开头的,都视为待上传图片,上传成功后再把src替换成服务器URL。

这条路线对用户的交互干扰最小,粘贴内容先展示出来,图片后面慢慢换。但坑也很明显:file://引用的图片在渲染阶段就已经破图了,afterPaste里你拿到的img节点虽然存在,但图片内容读不到,只能删掉或者放个占位提示;而data:的图虽然能显示,但如果图片数量多、体积大,页面会先渲染出一大段base64,用户感官上会觉得编辑器卡一下。

另外这条路线还有一个隐蔽问题:遍历DOM时会把用户之前手动插入的普通图片也扫进去。虽然可以靠src前缀区分,但总归多了一层判断逻辑,代码没有路线A那么纯粹。

2.3 路线C:不做拦截,让医生手动传一次

如果项目工期紧、后端接口没法动,那可以先退一步:粘贴时只保留文字,图片在粘贴后统一变成空位,然后让医生点击工具栏的图片上传按钮,一张一张自己传。实现上只需要配置好filebrowserUploadUrl,让CKEditor的图片对话框指向你的上传接口。

这条路线实现成本最低,但使用体验最差。医生从Word复制一份多页病历,里面可能有五六张检验报告截图,如果每一张都要重新另存为文件再点上传,实际使用中医生宁愿不贴了。我觉得它可以作为临时过渡方案,但不建议作为正式交付。

2.4 版本差异先确认,别套错代码

动手前先看一眼项目里用的是CKEditor哪个版本,这比写代码更重要。老HIS系统里存量最多的是CKEditor 4,写法就是上面说的editor.on('paste')加上对evt.data.dataValue做HTML字符串处理,这也是本文示例默认的版本。

如果你遇到的是CKEditor 5,那上面的代码基本不能直接抄。CKEditor 5的重写发生在clipboardInput事件,并且可以通过evt.data.dataTransfer.files直接拿到剪贴板里的图片文件对象,不再是从HTML字符串里抠base64。示例需要重构成读取File、调用上传、再插入图片模型节点的方式。

CKEditor 6目前在医院存量项目里很少见,遇到的可能性极低,这里不展开。总之先认清版本,再决定去哪找示例。

3. CKEditor 4粘贴图片自动上传示例

这一节给出一段可以直接运行的完整示例。示例覆盖了监听粘贴、提取base64、转Blob、上传、回填URL、插入编辑器这一整条链路。后端接口不依赖任何框架,你能看懂字段结构就可以自己实现。

3.1 直接可用的JS示例

假设页面里已经有一个名为editor1的CKEditor实例,下面这段脚本放到初始化之后执行。

// 把dataURL转成Blob对象,便于放入FormData function dataURLToBlob(dataURL) { var arr = dataURL.split(','); var mime = arr[0].match(/:(.*?);/)[1]; var bstr = atob(arr[1]); var n = bstr.length; var u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); } // 上传Blob,返回服务器图片URL function uploadBlob(blob) { return new Promise(function (resolve, reject) { var fd = new FormData(); fd.append('file', blob, 'clipboard_' + Date.now() + '.png'); var xhr = new XMLHttpRequest(); xhr.open('POST', '/his/attach/upload', true); xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { var json; try { json = JSON.parse(xhr.responseText); } catch (e) { reject(new Error('上传接口返回的不是JSON')); return; } if (json.code === 0 && json.data && json.data.url) { resolve(json.data.url); } else { reject(new Error(json.msg || '上传失败')); } } else { reject(new Error('HTTP ' + xhr.status)); } }; xhr.onerror = function () { reject(new Error('网络错误')); }; xhr.send(fd); }); } var editor = CKEDITOR.instances.editor1; editor.on('paste', function (evt) { var html = evt.data.dataValue; if (!html || html.indexOf('data:image') === -1) { return; } // 阻止CKEditor默认插入,等异步处理完成后再自己插入 evt.cancel(); // 匹配所有data:image开头的字符串,无论它在img还是v:imagedata上 var dataRegex = /data:image\/(png|jpe?g|gif|bmp|webp);base64,[^"'\s]+/gi; var dataUrls = html.match(dataRegex); if (!dataUrls || dataUrls.length === 0) { return; } var tasks = dataUrls.map(function (dataUrl) { var blob = dataURLToBlob(dataUrl); return uploadBlob(blob).then(function (fileUrl) { return { oldUrl: dataUrl, newUrl: fileUrl }; }); }); Promise.all(tasks).then(function (items) { var newHtml = html.replace(dataRegex, function (matched) { var found = items.filter(function (item) { return item.oldUrl === matched; })[0]; return found ? found.newUrl : ''; }); editor.insertHtml(newHtml); }).catch(function (err) { console.error('粘贴图片上传失败,保留原始内容', err); editor.insertHtml(html); }); });

这段代码有几个关键点需要展开。

第一,evt.cancel()的作用是阻止CKEditor默认把原始HTML插入编辑区域。如果你不调用它,编辑器会先插入一段包含大量base64的内容,然后再由你自己的逻辑插入一份新内容,最终页面里会出现两份粘贴内容,这是很多人第一次写类似功能时最容易踩的坑。

第二,正则里把jpe?g写成了jpe?g,目的是同时兼容jpg和jpeg两种后缀。[^"'\s]+保证了base64字符串不会误吞掉后面的HTML引号或空格。如果你碰到过base64里有换行符导致匹配不上的情况,可以在+后面加一个\s*的容错,但我建议后端在上传前统一做一次换行清理,前端不需要过度容忍脏数据。

第三,Promise.all的方式会等所有图片全部上传完成后再一次性插入。如果某个文件上传失败,会走catch分支,把原始HTML原样插回去。这样图片虽然会变成base64显示,但至少内容没有丢,医生可以截图后再手动替换。这个兜底策略比“上传失败就什么都不给”要友好得多。

第四,上传函数里用了XMLHttpRequest而不是fetch,主要是考虑老HIS项目里可能存在IE兼容需求。虽然现在主流环境基本都支持fetch,但医院内网里总会有几台旧电脑,用XHR能省掉不少兼容问题。

3.2 后端最小接口约定

前端代码里的/his/attach/upload接口,后端需要做哪些事才能接住?给一个最小约定,照着实现就行。

项目要求说明
请求方式POSTmultipart/form-data
字段名file对应前端FormData里append的file
鉴权必须校验当前登录医生的session,没有登录直接返回401
大小限制建议单张10MB以内超出返回友好提示,不要抛出500
返回格式JSON{code:0,msg:"",data:{fileId:"",url:""}}
URL形式相对路径示例:/his/attach/download?fileId=xxx

其中最重要的是URL形式。我见过不少HIS项目在附件上传接口里直接返回http://192.168.1.100:8080/his/attach/download?fileId=xxx这种带IP和端口的绝对路径,开发环境测试时一切正常,等到医院网络调整、服务器迁移或上了负载均衡之后,历史病历里的图片全变死链。更合理的做法是后端只返回相对路径,前端在编辑器HTML里也存相对路径,等最终渲染时由浏览器自动拼当前域名。这样无论服务器IP怎么变,只要同一个域名能访问到下载接口,历史病历就不会出问题。

3.3 旧项目里怎么嵌进去

不同技术栈的老HIS项目,嵌入这段逻辑的方式略有差别。

如果是传统JSP页面,直接在页面底部写一个<script>块,把上面的JS代码复制进去,再在页面初始化时调用CKEDITOR.replace('editor1', {...})即可。关键是确保脚本在CKEditor实例创建后再绑事件,否则CKEDITOR.instances.editor1还是个undefined。

如果是Vue项目,建议在mounted钩子里或者编辑器组件初始化的回调里完成绑定。CKEditor 4本身没有官方Vue组件,很多项目是引入原生脚本后用this.$nextTick去replace的。这时要注意,事件绑定不要写在组件的created阶段,因为编辑器DOM还没有渲染出来。

如果是.NET老项目,后端上传Action需要注意IIS默认请求长度限制。看到接口返回404或413这类状态,先检查web.config里的maxRequestLength,必要时调大,否则图片粘贴上传会被IIS前端直接挡掉。

3.4 初始化配置的三个要点

示例代码能跑起来,还有一个前提条件:CKEditor的初始化配置不能把允许的内容范围卡得太死。

第一,配置extraAllowedContent: 'v:shape[*];v:imagedata[*]'。如果编辑器不允许VML相关标签,粘贴过来的老式Word图片在进入编辑器之前就会被过滤掉,后面再怎么处理都来不及。

第二,不建议把allowedContent设成false或莽开全部内容。虽然这样能让各种标签都放进来,但同时也意味着Word带过来的大量垃圾样式、甚至潜在的脚本标签都能进入编辑器,对病历系统的安全性是威胁。正确的做法是保留pastefromword插件,让它先清理一遍,再通过上面的粘贴逻辑处理图片。

第三,pasteFromWordRemoveStyles这个参数要按实际需求设置。如果病历排版要求严格,就保留Word的部分样式;如果只要求正文内容,可以去掉字体颜色等冗余样式。医院通常比较在意表格边框和标题样式,所以默认值别乱改,先测一版再决定。

4. 从“能上传”到“能存病历”的四个关键设计

示例写完了、后端接口也通了,图片能在编辑器里显示了,这算“能上传”了。但放到HIS环境里,离“能存病历”还差几步。这四步是很多网上示例完全不会提到的,也是现场最容易翻车的地方。

4.1 上传接口必须鉴权,不能裸奔

我见过有开发图省事,把上传接口写成任何人都能POST的公开接口,理由是“医院HIS是内网系统,没人攻击”。这想法很危险。医院内网里同样有扫描器、有测试脚本,甚至会有外包人员连进来;而病历图片里往往带着患者姓名、诊断、检查报告,一旦这个接口被滥用,或者被用来上传脚本文件,后果是整个附件目录都可能被拖下来。

最小限度要做三件事:接口校验当前登录用户是否具备医生或护士角色,没有有效session直接拒绝;上传文件扩展名做白名单,只允许png、jpg、jpeg、gif、bmp、webp;文件存储目录禁止执行脚本,医院用的Windows Server IIS或Linux Nginx都要显式配置。

再进一步,建议把上传人和上传时间记录进日志。病历属于医疗文书,后期一旦需要审计、追溯,这些信息就是线索。这个要求不小,但真出事时能救命。

4.2 图片必须和病历文书关联,而不是散落一地

最典型的错误设计是:上一篇博文里说“返回URL,存进HTML字段就完事”。短期内看起来能用,但长期问题很多——同一张图片被医生重复粘贴到两份不同病历时,会存两份一模一样的文件;如果某天需要调阅某位患者的全部影像附件,没有任何索引可以查;如果病历发生修订,旧版图片文件没有保留策略,磁盘会被撑爆。

建议至少建一张附件表:

create table emr_attach ( file_id varchar(32) primary key, biz_id varchar(32) not null comment '业务ID,例如病历文书ID', biz_type varchar(20) not null comment '业务类型,例如emr、exam', file_name varchar(255) not null, file_path varchar(500) not null, file_size int not null, mime_type varchar(100) not null, upload_user varchar(50) not null, upload_time datetime not null );

编辑器HTML中只需要存/his/attach/download?fileId=xxx这样的引用即可。这也是为什么后端返回结构里要同时给fileId和url:url用于编辑器和最终展示,fileId用于关联表、打印、审计等后续环节。

4.3 打印、归档和电子签章的兼容问题

病历最终要打印归档,还要过电子签名。这两个环节经常把图片全外链的系统坑一遍。

先说打印。如果打印走的是浏览器直接打印,当前session还在,同源的相对URL能正常加载,问题不大。但如果医院用的是独立的打印服务、报表服务,那个服务通常是无状态的HTTP调用,不携带用户Cookie,那么图片URL里的session校验就会失败,打印出来的病历上全是空框。我给这类项目的建议是:下载接口支持短期token,比如/his/attach/download?fileId=xxx&token=xxxxx,token由打印服务调用时向认证中心换取,有效期10分钟,用完即弃。这样既不影响浏览器端使用,也不会破坏无状态架构。

归档和电子签章类似。签章系统要取文书的“原文”做哈希,如果原文HTML里嵌的是满屏base64,哈希值不稳定而且计算量大;如果改成相对URL引用,则签章系统需要能访问到附件下载接口。这一点在方案设计的时候就要和签章厂商确认清楚,别等到上线联调才发现两边对不上。

4.4 批量图片和体积的性能优化

医生从Word粘贴一份出院小结,里面可能带了十几张报告截图,每张2MB到5MB不等。如果前端直接把这些base64转Blob然后逐一POST,页面大概率会卡住,上传耗时也会让医生不耐烦。

我踩过的最有效的方法是前端压缩后再上传。用canvas把图片宽度压缩到1200像素以内,质量设为0.8,一张原图5MB的检验报告压完之后一般在200KB左右,完全能满足病历存档的阅读需求。压缩之后的图片体积明显减小,上传速度提升好几个量级,编辑器也不会因为超大base64卡顿。唯一要提醒的是:不要压缩病理切片这类对细节极其敏感的原图,HIS里那种普通的报告截图、伤口照片、体征照片都适合压缩。

如果单次粘贴的图片非常多,可以限制一次最多处理10张,超出部分提示分批粘贴,避免一个请求携带太多文件导致后端超时。

5. 现场排查与避坑速查表

示例改了、接口通了,真正上线时还会有一堆现场问题。我按实际踩坑频率列一张速查表,照着排查就能省下不少时间。

5.1 粘贴后图片是红叉,别急着改代码

先用控制台看一下这段红叉图片的src到底是什么。如果是file:///开头,说明图片引用的是本地路径,浏览器出于安全限制拿不到内容,这属于剪贴板机制本身的限制,不是你代码能解决的。

遇到这种情况,最务实的应对是告诉医生:不要直接从Word复制图片,改用截图工具把图片区域截下来再粘贴。截图会以位图格式进入剪贴板,浏览器在粘贴时能拿到真正的图片文件,上传逻辑才能捕获它。如果医院还在用老旧的IE模式浏览器,这一步尤其重要——老版本Edge和IE从Word复制图片时大概率生成file://引用,而现代Chrome内核浏览器在多数场景下会生成base64,可操作性完全不同。

5.2 图片上传成功但顺序乱掉

异步上传时最容易犯的错误是:每张图片上传完成后立刻替换HTML,结果返回时间不一致,图片顺序变得乱七八糟。解决办法就是示例里用的Promise.all,所有请求全部完成后一起替换,图片在HTML里保持原有顺序不变。

如果你不想等所有图片上传完再插入,也可以换成串行上传:定义async循环,逐个上传逐个替换。串行节省不了总时间,但可以避免并发请求打爆小带宽的内网上传链路。

5.3 排查流程:三个地方看一眼

遇到粘贴图片相关的问题,我一般先看三处。第一处是浏览器控制台的Network标签,确认粘贴时有没有发出/his/attach/upload请求。如果根本看不到请求,说明事件绑定没生效,或者数据里根本没有base64图片,问题在编辑器配置或事件写法;如果请求发出去但报401或404,说明接口路径或者权限校验有问题;如果请求500,则要看后端日志。

第二处是编辑器初始化配置。把extraAllowedContent和插件列表打印出来看一遍,确认VML相关标签没有被提前过滤。

第三处是浏览器版本。遇到奇怪现象,先换Chrome内核浏览器试试,很多时候所谓“兼容问题”只是因为医院内网默认浏览器太老,而项目里用到的现代Web API不支持。

5.4 常见问题速查表

现象可能原因处理建议
粘过来全是红叉file://本地路径引用改用截图粘贴,或改用Chrome内核浏览器
粘贴后没任何图片且没有网络请求事件没绑定或编辑器实例拿错确认CKEDITOR.instances.editor1存在
上传后图片重复出现没有调用evt.cancel,默认插入和手动插入叠了A方案中确保先cancel再insertHtml
图片顺序错乱并发上传后按完成时间替换Promise.all处理完再统一替换
接口400/413后端请求体限制调整IIS maxRequestLength或Nginx client_max_body_size
接口401session校验失败确认上传请求携带了Cookie凭证
编辑器里显示正常但打印为空框打印服务无状态不携带session下载接口改用临时token
老电脑粘贴大图卡死base64太大或atob内存溢出前端压缩图片后再上传

5.5 最后一个排查建议

给实施工程师同事的最后一句话:别在编辑器源码上死磕三天才发现是后端接口没通。所有粘贴上传的路径都是“前端拦截—发请求—返回URL—回填编辑器”这条线,中间任何一个环节断了,现象都会是“图片显示不出来”。先看Network,再看后端日志,一般十分钟之内能定位到问题出在哪一节。

我自己在给医院做升级时踩过最狠的一次坑,是测试环境把上传地址写成了开发机的IP端口,当时一切正常,上线后所有图片请求都打到了那台已经关掉的开发机上。后来把所有附件URL全部改成相对路径才算彻底解决。从那以后我养成了一个习惯:凡是编辑器里的图片引用,一律不在HTML里写死IP,只存相对路径,渲染时由浏览器决定域名。这个小习惯,推荐给你也试试。

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

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

立即咨询