
DICOM截图这块需求我是在做PACS配套报告系统时碰上的。医生在影像工作站把窗宽窗位调到满意截图、粘贴到CKEditor富文本里再保存到PHP后端。听起来就是粘贴图片→上传→回显三件事但实际上这条链路里任何一个环节处理不当存下来的图要么糊了要么文件本身已经损坏要么多出一堆莫名其妙的中间格式。今天就把我从能贴到能无损保存的完整改造过程拆开讲清楚代码和思路都按可以直接复现的标准来写。先说清楚一个容易被忽视的事实DICOM截图和普通网站截图在无损这件事上的要求完全不同。普通图片丢了几个像素看不出来但医学影像上哪怕是一个像素的灰度偏移都可能影响诊断结论。你要么保存医生截下来的那张位图本身做到bit-level一致要么连DICOM源文件一起存保留完整的像素数据和头信息。这两种需求对应两套做法后面会分别展开。下面从问题根源开始梳理。1. 这个需求的真正难点DICOM截图不是普通图片1.1 医生粘贴过来的到底是什么在医学影像系统里DICOM文件存的是原始像素数据常见的有16位灰度有些甚至带浮点数据。但医生在屏幕上看到并截图的内容是经过窗宽窗位映射、伪彩、缩放算法处理之后的8位显示图像。什么意思呢屏幕上的一张截图已经是从16位像素值压缩映射到8位显示值的结果。所以当医生在CKEditor里粘贴DICOM截图时浏览器剪贴板里基本是两个可能一张image/png或image/jpeg的位图截图来自截图软件或者影像工作站自带的复制功能一个真实存在的.dcm文件对象来自文件管理器拖拽或复制粘贴。前者的核心诉求是这张显示图不能在我保存时被二次压缩、二次缩放、变换色深后者的核心诉求是DICOM文件字节一个都不能少。搞清楚这个区别后面的代码才有方向。1.2 无损转存的两种理解很多人一提无损就默认是PNG格式因为PNG本身是无损压缩。但这里有个坑PNG文件虽然是无损格式不代表你把PNG重新用PHP的imagepng()编码一遍还是无损的。仔细说PNG的像素数据在经过二次编码时如果GD库的上下文里启用了缩放、重新采样、颜色模式转换哪怕只是另存为也会改变图像的实际像素排列丢掉PNG原来带的辅助块文本信息、gamma校正、ICC色彩配置等。对一个普通web开发来说这无所谓但对医学影像来说这些辅助信息和像素内容同样重要。所以我把这里的无损严格定义为两层医生粘贴出来的是什么字节最终存到磁盘上的就是什么字节中间不做任何解码、重编码如果粘贴的是DICOM文件那么整个文件按二进制整体落盘既不抽像素数据也不转格式。只要做到这两条转存环节就不会给影像质量添乱。1.3 浏览器和CKEditor默认的图片处理方式CKEditor在浏览器里捕获粘贴事件后会把图片转成data:URL或者通过编辑器自己的上传通道处理。默认情况下粘贴进编辑器的截图会以base64字符串的形式内联在HTML里然后跟随整个表单一起POST到服务端。问题就在这里base64会让原始数据膨胀约33%一张5MB的DICOM截图到编辑器里能变成7MB的字符串内联在HTML里意味着它和文本内容耦合在一起后续你要归档、检索、复制病例都得从HTML里拆操作非常别扭很多系统在保存这种富文本时会走一层转义、过滤、二次替换的逻辑任何一个环节把图片数据截断、替换成空图片就永久丢了如果中间层再对图片做一次base64_decode再用字符串方式写文件遇到二进制里的特殊字节序列文件头就损坏了。所以正确思路是在CKEditor贴上图片时第一时间拦截原始数据通过独立的接口用二进制方式上传编辑器里只保留一个服务端URL引用。这与文本内容是两套生命周期归档也好审计也好都清晰得多。2. 改造CKEditor粘贴链路在编辑器拿到数据之前动手2.1 CKEditor 4下的事件挂载与示例代码CKEditor 4的粘贴事件挂在实例上监听paste即可。这里的关键是用evt.data.dataTransfer而不是自己去解析HTML内容因为dataTransfer里才是真正的原生物件。// CKEditor 4 var editor CKEDITOR.replace(editor1); editor.on(paste, function(evt) { var dataTransfer evt.data.dataTransfer; if (!dataTransfer) { return; } var items dataTransfer.items || []; // 遍历剪贴板中的数据项 for (var i 0; i items.length; i) { var item items[i]; if (item.kind file) { var file item.getAsFile(); if (!file) continue; // 判断是位图还是DICOM if (file.type image/png || file.type image/jpeg) { evt.cancel(); // 阻止编辑器默认的base64内联 uploadPasteImage(file); // 走独立上传 } else if (file.name file.name.toLowerCase().endsWith(.dcm)) { evt.cancel(); uploadDicomFile(file); } } } });注意evt.cancel()的时机。必须在编辑器把图片解析成内联img之前取消默认行为。如果已经进了编辑器那就晚了只能从HTML里抠base64又回到老路。2.2 CKEditor 5的事件处理差异CKEditor 5的事件体系变了监听的是editor.editing.view.document上的clipboardInput事件。为什么用这个而不是paste因为clipboardInput会在粘贴数据真正进入编辑模型前触发拦截起来更干净。// CKEditor 5 editor.editing.view.document.on(clipboardInput, (evt, data) { const dataTransfer data.dataTransfer; if (!dataTransfer) return; const files Array.from(dataTransfer.files || []); for (const file of files) { if (file.type image/png || file.type image/jpeg) { evt.stop(); // 阻止进入编辑流程 uploadPasteImage(file); return; } if (file.name file.name.toLowerCase().endsWith(.dcm)) { evt.stop(); uploadDicomFile(file); return; } } });这里要特别说一句evt.stop()和evt.preventDefault()在CKEditor5的view事件里行为不太一样stop()能阻止事件继续向下传递到模型插入这一步是你想要的。只调用preventDefault()有时还不够编辑器的某些内部插件比如自动嵌入图片插件可能还是会接管。2.3 区分截图和DICOM文件类型判断不能只靠扩展名服务端最容易忽略的就是类型判断。前端可以按file.type和扩展名粗分但后端一定不能只信这个要按文件头来校验。DICOM文件的识别很简单文件偏移128个字节处第129到132字节如果是DICM四个ASCII字符那基本可以确定是DICOM Part 10格式。PNG文件头是8字节的固定签名89 50 4E 47 0D 0A 1A 0A。JPEG文件头一般是FF D8开头。前端代码里我建议这样处理文件读取function uploadPasteImage(file) { const formData new FormData(); formData.append(file, file, file.name || paste.png); fetch(/api/upload-image, { method: POST, body: formData }).then(res res.json()).then(data { if (data.url) { insertImageIntoEditor(data.url); } }); } function uploadDicomFile(file) { // 用FileReader读原始二进制或者直接用FormData传Blob const formData new FormData(); formData.append(file, file, file.name); formData.append(type, dicom); fetch(/api/upload-dicom, { method: POST, body: formData }).then(res res.json()).then(data { if (data.url) { insertDicomLinkIntoEditor(data.url); } }); }有人可能会问DICOM文件能不能也用FormData直接传能但要注意有些浏览器里从剪贴板取出的文件对象是File类型直接用FormData没问题。真正麻烦的是某些场景下你拿到的不是File而是Blob没关系FormData不挑照传。2.4 为什么要把图片转存做成独立接口编辑器保存文章是一回事图片落盘是另一回事我强烈建议拆开。原因有三个第一DICOM截图和DICOM文件都可能是几MB到几十MB的体量如果跟随文章一起提交大请求会被PHP的post_max_size、upload_max_filesize卡死。拆开后可以单独给图片接口放宽限制。第二文章内容里图片以URL引用存在后续做缩略图、水印、存储迁移都很方便不需要去解析富文本里的base64。第三从审计角度独立的图片接口可以记录操作人、来源病例、时间戳对医疗系统来说这是刚需。3. PHP端无损落盘从请求体到磁盘的每一步3.1 读取请求体的正确方式php://input而不是$_POST很多人在PHP里接收POST文件下意识用$_FILES但如果你用FormData直接传blob接收端确实可以通过$_FILES拿到。不过如果你想兼容更底层的场景比如前端直接用fetch传ArrayBuffer、或者你打算把上传接口设计成接收原始bodyphp://input是最通用的方案。我实际用的是两套方案方案AFormData文件上传PHP端用$_FILES接方案B前端把文件读成ArrayBuffer之后直接用application/octet-stream发原始二进制PHP端用php://input接。方案B在移动端、跨域环境里更省事而且不用处理multipart的边界解析。下面重点讲方案B因为坑最多。?php // upload.php $raw file_get_contents(php://input); if ($raw false || $raw ) { http_response_code(400); exit(empty body); } $len strlen($raw); // 这里要判断文件大小 if ($len 50 * 1024 * 1024) { http_response_code(413); exit(file too large); }用php://input有一个关键坑如果脚本里的某个地方已经读过php://input比如框架的请求解析这里再读就是空字符串。所以这个接口要尽量放在框架层之前处理或者用框架里不预读body的原生路由。3.2 按文件头判断真实格式别被扩展名骗了这是整个转存流程里最重要的验证步骤。文件是否真的是PNG、JPEG、DICOM不是看前端传的file.name和file.type而是看文件头字节。我把几种常见情况做了个校验函数?php function detectFileType(string $raw): string { // PNG签名 if (strncmp($raw, \x89\x50\x4E\x47\x0D\x0A\x1A\x0A, 8) 0) { return png; } // JPEG签名 if (strncmp($raw, \xFF\xD8, 2) 0) { return jpeg; } // DICOM Part 10格式128字节导言后是DICM if (strlen($raw) 132 substr($raw, 128, 4) DICM) { return dicom; } // 常见的又一种情况从某些工作站复制出来的是BMP if (strncmp($raw, BM, 2) 0) { return bmp; } return unknown; }为什么要这么较真我遇到过不止一次医生从Windows影像工作站复制出来的截图到了浏览器里file.type是image/png但真实文件头明明是BMP。如果你只看file.type就按PNG去处理结果保存的文件扩展名和内容不匹配后续PACS归档时读出来的全是损坏资源。3.3 二进制安全写入细节决定成败文件识别完了写入方式也必须有讲究。PHP的文件写入建议用fopen配合fwrite而不是file_put_contents虽然两者最终效果差不多但遇到大文件时fopenfwrite循环可以控制写入进度也能更好地处理锁。?php function writeBinaryFile(string $path, string $data): bool { $fp fopen($path, wb); if ($fp false) { error_log(Failed to open . $path); return false; } // 加锁防止并发写同一个文件 if (!flock($fp, LOCK_EX)) { fclose($fp); return false; } $offset 0; $length strlen($data); while ($offset $length) { $written fwrite($fp, substr($data, $offset, 8192)); if ($written false || $written 0) { flock($fp, LOCK_UN); fclose($fp); return false; } $offset $written; } flock($fp, LOCK_UN); fclose($fp); return true; }注意wb里的b这个很重要。在Windows环境下如果没有b标记PHP的fwrite会把某些字节序列当作换行符转换二进制文件直接写坏。在Linux下通常没区别但为了跨平台可移植wb是必须的。另一个隐藏的细节flock加锁只是防同一脚本进程并发写同一个文件但如果你的文件名是随机生成的那基本不会撞锁。这个锁更多是防御性的对于在企业级框架里多进程运行PHP-FPM的环境它能兜住一层。3.4 存储目录、随机文件名与会话安全医疗数据无小事存储目录不能放在web根目录下直接被人访问。我会把上传目录放在/data/medical_uploads/这种web访问不到的位置然后通过一个单独的受控接口去读取文件这样既能在读取接口里做权限校验、操作审计也能避免任意URL直接拉取病人影像的风险。文件名绝对不能沿用原始文件名。一方面是为了防目录穿越如果文件名里带../直接拼路径就会出事另一方面是为了保护隐私。我用的是随机文件名?php $randomName bin2hex(random_bytes(16)); // 32位十六进制随机串 // 按日期分目录避免单目录文件数过多 $dateDir date(Ymd); $dir /data/medical_uploads/ . $dateDir; if (!is_dir($dir)) { mkdir($dir, 0750, true); } $ext $fileType; // png/jpeg/dicom/bmp $finalPath $dir . / . $randomName . . . $ext;目录权限给0750就够了组内用户可读写其他人不需要任何权限。文件权限默认走umask但我建议落盘后显式chmod($finalPath, 0640)确保不会意外变成全局可读。这里补充一个思路要不要按患者ID归档很多人第一反应是把截图放到病人的文件夹下方便将来调阅。实际项目中我建议别这么干原因有二一个患者可能有数千张影像截图文件夹扁平化速度很快反倒难管理文件名如果包含患者ID在日志、数据库、URL里都有泄露风险。正确做法是数据库里存一条关联记录患者ID和文件ID分离文件服务层不知道也不关心这是谁的片子。这样既支持按患者查询又不会在文件层面泄露身份信息。4. 无损校验链路保存完如何确认没被糟蹋4.1 落盘后立即计算哈希上传完了不能直接返回成功就完事。我会在写入文件后立刻算一个SHA-256?php $hash hash_file(sha256, $finalPath);然后把文件路径、大小、哈希记到数据库里。这个哈希后面有三大用处客户端拿到服务端返回的哈希后可以和本地文件的哈希做一次比对做到端到端的无损确认以后PACS归档时可以用哈希去重相同内容的截图不会重复存万一存储介质出问题审计日志可以追溯文件是否被改动过。前端拿本地哈希的方式是读文件字节用crypto.subtle.digest但注意crypto.subtle只在HTTPS或localhost环境可用HTTP内网系统需要做降级处理async function sha256FromFile(file) { if (window.crypto crypto.subtle) { const buf await file.arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, buf); return Array.from(new Uint8Array(digest)) .map(b b.toString(16).padStart(2, 0)).join(); } // 降级方案用服务端返回的哈希做对比 return null; }HTTPS的降级处理我一般会让服务端返回哈希后直接信任服务端校验结果然后在前端做一个大小对比作为辅助。4.2 什么时候绝对不能碰GD库在转存链路里imagecreatefrompng()、imagejpeg()、imagescale()这些函数一个都不要出现。原因前面提过PNG第二轮编码不保证像素逐位一致。举一个真实案例。我曾经在处理一批截图时只是想着顺手给图片加个白边用GD库转了一圈结果发现输出的文件虽然肉眼看起来一模一样但像素的RGB值和原始文件完全不同因为GD在重编码时做了一次颜色空间转换。对医生来说这也许无伤大雅但如果你是按DICOM灰阶图像做后续分析这种像素值的偏移就是事故。所以我的转存接口里有一套禁函数清单代码评审时专门盯着不使用GD函数重画不用base64_decode之后的字符串做正则替换不做缩略图、不转WebP、不转JPEG不把图片数据塞进JSON以后再解析保持独立的二进制流通道。4.3 定期抽查文件完整性的巡检方案上线久了你会发现有些文件会因为磁盘坏道、迁移出错、人为误操作而损坏。单靠落盘时算哈希不够因为损坏是后发的。我会写一个每天跑一次的巡检脚本对数据库里所有文件记录重新算哈希、核对大小不一致的立即标记并告警。?php // cron每天凌晨两点执行 $stmt $pdo-query(SELECT id, file_path, file_hash FROM uploads WHERE status active LIMIT 5000); foreach ($stmt as $row) { if (!file_exists($row[file_path])) { echo MISSING: . $row[id] . PHP_EOL; continue; } $realHash hash_file(sha256, $row[file_path]); if ($realHash ! $row[file_hash]) { echo HASH MISMATCH: . $row[id] . PHP_EOL; } }这个巡检不复杂但医疗系统里非常值得做。因为影像数据不允许意外,你只能靠机制兜住。5. 大文件与多图场景内存、流量和性能怎么平衡5.1 set_time_limit、内存上限和post_max_size的调整DICOM相关图片动辄几MB如果一张截图是4K分辨率PNG可能直接到10MB以上。PHP默认的memory_limit是128Mpost_max_size是8M不改的话第一条请求就挂。我的经验参数upload_max_filesize 50M post_max_size 60M memory_limit 256M max_execution_time 60注意post_max_size要比upload_max_filesize大因为POST里除了文件本身还有别的表单字段。如果走php://input读原始bodypost_max_size就是硬天花板别把这两个搞反否则你会在排查时浪费很长时间。5.2 base64上传是性能杀手能不用就别用我给系统做过一次压力测试同样一张6MB的PNG用base64内联再上传PHP端需要多分配约8MB内存做字符串解析用二进制流上传内存占用少了一半还不止。原因不复杂base64把3字节变成4字节膨胀33%加上字符串在PHP内部以zval存储每个字符又有额外的开销。如果你把一张10MB的图转成base64然后塞进JSONPHP内存占用破100MB很正常。所以接口设计上前端用FormData把File对象直接post或者用fetch发原始ArrayBuffer都行就是不要手动转base64再POST。5.3 多图场景下的异步上传与提交顺序医生可能在一条报告里贴三四张截图。如果一张一张等上传完成再插入编辑器交互上有点慢但可靠性最高。我实际采用的是即贴即传、本地占位的方案粘贴时先把文件传到独立接口上传成功后在编辑器里插入一个带loading占位符的img上传失败时显示重试按钮不阻塞正文编辑最终保存文章时编辑器里的img src已经是服务端URL数据库只存文本内容图片URL列表。这种设计有几个好处文章草稿不会因为大图没传完就卡住图片上传失败不会导致整篇文章保存失败图片资源和正文分离后续换存储、加水印、压缩缩略图都有回旋余地。前端伪代码function uploadWithRetry(file, editor) { // 先插入占位 const placeholder createPlaceholder(); insertHtml(editor, placeholder); uploadPasteImage(file).then(data { replacePlaceholder(placeholder, data.url); }).catch(() { // 占位符变成重试按钮 showRetry(placeholder, file, editor); }); }5.4 关闭服务器的output_buffering大文件上传往php://input读流时框架里开启的output_buffering会吃掉额外的内存因为输出缓冲区的数据也可能累计。这个通常不需要在代码层面处理但如果你在PHP-FPM Nginx环境可以留意下fastcgi_buffering对响应的影响。不过我这套方案核心是独立上传接口响应体只有一个JSON问题不大。6. 浏览器差异与移动端粘贴的坑6.1 同一张截图在不同浏览器里的表现能差多远Windows桌面端Chrome和Edge对截图粘贴都支持得不错dataTransfer.items里能拿到image/png或image/jpeg。Firefox对items的支持相对弱有时拿不到kind: file的项目这时需要走dataTransfer.files兜底。我写的兼容逻辑是function getFilesFromDataTransfer(dataTransfer) { const items Array.from(dataTransfer.items || []) .filter(item item.kind file) .map(item item.getAsFile()) .filter(Boolean); if (items.length) { return items; } // 兜底部分浏览器只有files return Array.from(dataTransfer.files || []); }这层兜底代码看着简单但能救回大约15%的粘贴操作。Safari在macOS上行为又不一样有时粘贴的PNG会变成text/rtf在items里拿不到图片只能从getData(text/html)里抠img标签的base64。这种情况基本是无解的只能优雅提示用户先保存到本地再拖拽上传。6.2 微信/企业微信内置浏览器粘贴图片的怪异行为医院一线医生的电脑上企业微信和微信几乎是标配。医生从影像系统里截好图转手粘贴到Web报告编辑器时微信内置浏览器的安全策略经常会拦截clipboardData的读取导致前端拿不到任何文件。踩过几次坑之后我的处理策略是在编辑器工具栏里放一个显眼的粘贴图片按钮如果检测到剪贴板里没有可用的图片文件就自动弹出文件选择框引导医生从本地选图配合拖拽上传drop事件因为拖拽是不受剪贴板权限限制的在界面文案上明确提示微信内浏览器不支持直接粘贴可用拖拽或上传按钮。不要迷信技术能解决所有问题工具层面给一条更顺滑的替代路径比偏要在受限浏览器里硬碰硬高效得多。6.3 iPad/安卓平板上的DICOM文件粘贴处理现在不少医院在推移动查房医生拿着平板在病区看影像。平板上的浏览器对粘贴DICOM文件的支持比桌面端还差因为操作系统层级就把DICOM当成未知文件类型。处理方式是把前端逻辑拆成两条线如果dataTransfer.files里能拿到.dcm文件走二进制上传通道如果拿不到就建议医生把DICOM文件先放到共享盘再从报告系统里用专门的DICOM选择器去关联。至少在我实测环境里iPad Safari从文件App复制粘贴DICOM文件到浏览器是能拿到File对象的但前提是用户得先长按文件→复制而不是从影像App里直接拖拽。这块交互细节如果能写进操作手册能减少很多工单。6.4 粘贴失败后的兜底上传组件我最后悔没早点做的一件事是在编辑器旁边增加一个完整的上传区组件。功能不复杂一个支持点击选择、拖拽上传的按钮上传成功后在光标处插入图片URL。虽然这看起来和粘贴无关但它替整个系统兜住了所有粘贴触达不到的情况。实际部署后粘贴失败类的工单量下降了约六成。组件设计上我参考了常见的上传交互div idpaste-uploader input typefile idfile-input acceptimage/*,.dcm hidden / button idupload-btn选择图片或DICOM/button div iddrag-zone或将文件拖拽到此处/div /div拖拽区域要监听dragover和drop两个事件dragover如果不如期preventDefault()drop就不会触发。这个细节在写拖拽上传时经常被忽略。6.5 多端兼容的最终检查清单拿我自己的项目举例最终在浏览器和场景层面的兼容策略是下面这样分享出来供参考桌面Chrome/Edge正常走剪贴板粘贴接收图片或DICOM文件桌面Firefox优先读dataTransfer.files粘贴不到就引导拖拽微信内置浏览器剪贴板权限受限直接推选择文件路径iPad Safari支持从文件App复制粘贴但拖拽不一定稳定Android Chrome粘贴图片基本可用DICOM文件需要看具体文件管理器支持程度。每一条都对应一个明确的前端分支逻辑把实现里最可能出现的意外提前灭掉。写在最后这套CKEditor PHP的无损转存链路我陆陆续续调了一个多月才算稳定。核心思路就一句话永远不要在资源链路上做无意义的二次编码截图像素长什么样DICOM文件长什么样落盘之后就还是什么样。做到这一点前面所有关于格式识别、二进制写入、哈希校验的功夫就都没有白费。如果后续要扩展我建议加一个原始DICOM转PNG缩略图的旁路服务专门用于列表页的快速预览。注意一定要走独立任务队列不要在用户上传的请求链路里同步做转码否则一张几百MB的DICOM能把你PHP进程挂死。既然做医疗影像性能和安全这两件事永远要在功能上线前就先想清楚。