上个月在给机械设计BOM系统做升级时,被一个看似不起眼的小问题卡了两天:工程师从Visio里画好的流程图,粘到TinyMCE富文本编辑器里,再保存到BOM系统的工艺备注字段,出来全是糊的。不光是图片模糊,有时候箭头被拉歪、文字区块错位,甚至整张图变成一片空白。群里老师傅直接甩了一句:“这系统还不如我在Word里粘贴得清楚。”后来我把Visio、TinyMCE、BOM系统这三层都翻了一遍,才彻底弄明白问题出在哪,也把这条链路完整修好了。这篇东西就是想把这段排查和改造经验写清楚,给正在做类似BOM、ERP或者文档管理系统集成的朋友一个能直接落地的参考。
1. Visio流程图在TinyMCE里失真的根因,不是像素不够这么简单
1.1 一次BOM备注里的“糊图”现象
我们系统里的机械设计BOM,每个物料节点可以挂一份工艺流程图,比如“轴类零件加工流程”“装配过程检验流程”。这些图基本都出自Visio,工程师习惯画完之后直接Ctrl+C复制,再切到浏览器里Ctrl+V粘贴进TinyMCE。表面上看,粘贴完编辑器里确实显示了一张流程图,颜色、线条都还在,所以绝大多数人根本不会多想,直接点保存。
问题在做BOM审核时集中爆发:有人的流程图在提交之后变成了一坨模糊的像素块,字看不清,连线变成“毛边”;有人的图在编辑界面看着是正常的,等审批流里走一圈再打开,尺寸被莫名放大了一倍;还有更离谱的,粘贴进来当时正常,第二天再打开那条BOM记录,图片裂了,浏览器显示一个小红叉。
这些现象的根源,并不是Visio画图质量差,也不是TinyMCE本身存不了图片,而是“从Visio复制到浏览器粘贴”这条路径里,经过了多种格式转换,每一步都可能丢东西。
1.2 剪贴板里装的不是一张图,而是一堆格式
很多人以为在Visio里按Ctrl+C,剪贴板里就是一张PNG或者JPEG。实际上,Office系列软件复制对象时,会同时往剪贴板里塞很多种格式,包括:
- 原始Visio对象(OLE对象)
- EMF/WMF矢量图元文件
- DIB位图
- PNG位图
- HTML片段(里面可能引用VML或内嵌图片)
当你把它粘贴到Word里,Word会优先选择OLE对象或者EMF,所以得到的依然是矢量效果;当你把它粘贴到浏览器里的TinyMCE时,浏览器和编辑器插件会从剪贴板里挑一种自己能处理的格式。Chrome等现代浏览器通常会把图片格式转为PNG,但问题是:这个PNG的分辨率可能只有96 DPI。Visio在复制时生成的PNG,更多是为了“快速预览”用的,不是为印刷或者高清屏幕准备的,一旦在BOM系统里被CSS拉伸,或者审批流里做了缩略图放大,糊掉是必然的。
这里还要特别提一下DIB格式。如果TinyMCE的粘贴插件在某种情况下拿不到干净的PNG,而选择了DIB,那存出来的图经常是带黑底、透明通道丢失的,放在BOM系统白色背景下一眼就能看出来。
1.3 真正要命的是VML和EMF这两个“历史遗留物”
Visio往剪贴板里塞的HTML片段,是带VML(Vector Markup Language)的。VML是微软在IE时代主推的矢量标记语言,在今天的Chrome、Edge(Chromium版)里已经不是原生支持的东西了。TinyMCE的历史版本里,PowerPaste插件会试图解析这种HTML片段,把里面的VML转成图片,或者直接保留VML标签。一旦转换失败,编辑器里就会出现一堆莫名其妙的<v:group>、<v:shape>、<o:OLEObject>之类的标签,保存到后端再读出来,浏览器不认,图片自然就裂了。
EMF则是另一种情况。Visio复制时生成的EMF是矢量格式,理论上放大不会失真,但绝大多数网页和TinyMCE不直接在<img>标签里渲染EMF。如果你用images_upload_handler把剪贴板里的数据直接当图片上传,后端拿到的可能是一个无法被前端识别的EMF文件,硬要用<img src="xxx.emf">去展示,结果就是空白或者浏览器弹下载框。
总结一句:失真的核心不是“图片被压缩了”这么一个简单原因,而是Visio剪贴板的多格式输出与TinyMCE的HTML解析机制之间发生了错配。
2. TinyMCE默认行为为什么扛不住Visio流程图
2.1 Paste插件到底在处理什么
TinyMCE默认有一个paste插件,它负责把用户粘贴进来的内容转换成编辑器可用的HTML。对于文本,它处理起来非常轻车熟路;对于图片,逻辑就复杂了。
当你在TinyMCE里粘贴图片时,paste插件会经历一个类似这样的判断:
- 读取剪贴板的
text/html内容; - 从里面提取图片相关的标签或二进制数据;
- 根据配置决定是把图片保留为Base64、转成Blob上传,还是丢掉;
- 最终生成一段HTML插入编辑器。
粘贴Visio流程图时,剪贴板里那堆HTML/VML片段会被第一个读到。TinyMCE默认的PowerPaste模式会尝试把VML转成图片,但它的转换器主要针对简单的Office文本,对于Visio这种复杂的图形组合,转换出来的往往是一个分辨率很低的预览图,而且位置信息大概率会错乱。
很多开发者遇到问题后第一反应是“那我直接把paste_data_images设置成true,让它把图片当作独立图片粘贴不就行了”。这个方向是对的,但还不够。因为Visio粘贴过来的“图片”经常不是一张干净的位图,而是一段混合了VML和图片的HTML,你的处理逻辑必须针对这种混合内容做专门清洗。
2.2 位图和矢量图在粘贴时的不同命运
如果粘贴的是普通截图工具截出来的PNG,TinyMCE处理起来很轻松:识别到剪贴板里有PNG数据,直接转成Base64或者上传,完事。流程图这种场景的问题在于,Visio里的图形元素在剪贴板里是“矢量描述”和“位图预览”同时存在的。编辑器更倾向保留HTML描述,但HTML描述里是过时的VML,最终表现就是:
- 个别浏览器自动把VML渲染成一个低分辨率的图片;
- 某些版本的TinyMCE会把VML原样塞进
content,导致后端拿到一堆垃圾标签; - 图片在编辑器里看起来有一层灰蒙蒙的背景,因为VML转成的位图带了默认底色。
换句话说,位图粘贴时是“图”,Visio粘贴时是“图+一堆描述信息”,TinyMCE需要从这堆描述里“拆”出一张能用的图,而拆出来的结果完全取决于Visio给了什么。Visio给的预览PNG是96 DPI的,那拆出来就是96 DPI的,放到BOM系统的高分屏上就是糊的。
2.3 BOM系统场景的特殊约束
机械设计BOM系统跟普通内容管理系统还不一样,它对流程图有几个额外的约束:
- 流程图的审批链路很长:工艺人员绘制的流程图要经过校对、审核、批准,每一级都可能打开附件预览,图片在不同缩放级别下都必须保持清晰可读。
- 图纸信息密度高:方框里的文字、箭头上的标注、线条之间的间距都很小,一个普通流程图里可能塞了几十个文本标签,分辨率不够会直接导致文字无法辨认。
- 后端存储与回显链路多样:BOM数据可能在Web端查看、在打印模板里渲染、在移动端审批应用里回显。同一张图片要适应这些场景,靠一张96 DPI的小位图是撑不住的。
所以我后来在方案设计时给自己定了一个原则:不能让Visio的剪贴板格式来决定我们的存储质量,必须把图片格式的控制权从前端粘贴动作里夺回来。
3. 源头治理:Visio导出这一步就要做对
3.1 高分辨率PNG导出:分辨率设到多少才够
如果团队暂时不打算上SVG,最低限度也应该用PNG导出。Visio里不要再用“复制”功能了,改成“文件 -> 导出 -> 更改文件类型 -> PNG”。
导出时会让你填分辨率设置。根据我的经验,机械BOM里的流程图至少要做到200 DPI以上,推荐300 DPI。以一张A4横向画布为例,300 DPI导出的PNG分辨率大概是3508×2480像素,一张图大约1~3 MB,能保证在BOM系统页面里无论放大到150%还是缩略显示,文字都清楚。
导出时记得看一下“大小”选项。Visio默认导出的是当前画布大小,如果你以前把画布拖得特别大,或者页面上有很多边距空隙,导出的PNG里会有一大片空白。我一般会在Visio里先用“设计 -> 大小 -> 适应绘图”把画布裁剪到内容刚好放下的范围,再导出。这一步虽然简单,但能明显减小图片体积,也能避免BOM系统回显时出现大块留白。
3.2 直接存SVG:机械流程图最稳的矢量格式
我最终在系统里选了SVG作为流程图的主流存储格式。原因很直接:Visio从2013版本开始就支持“另存为SVG”,导出结果保留了矢量信息,文字变不变形、线条清不清晰,都不再受分辨率限制。
Visio导出SVG有两种常见做法:
- “文件 -> 另存为 -> 选择SVG格式”,这种方式适合单张流程图;
- 将多张图放在一个Visio文件的不同页里,用“另存为”时没法一次性导出多页,需要用到Visio自带的“导出”功能或者VBA批量处理。
对于BOM系统里大量节点都要上传流程图的情况,我建议给工艺人员做一个简单的VBA宏,把当前Visio文件里的每一页都导出成独立的SVG文件,命名规则用图号或者物料编码,这样能省掉大量手工操作。
还有一个细节必须注意:Visio导出的SVG默认尺寸是跟随画布的,但其中的文本字体不一定能嵌入。如果BOM系统部署的服务器或者客户端没有安装对应的字体,SVG里的文字会被替换成其他字体,可能造成排版变化。我在项目里为了规避这个问题,要求工艺流程图统一使用宋体或微软雅黑,这两种字体在公司内网机器上基本都是标配。
3.3 画布、缩放、字体统一,避免“到编辑器里就变形”
Visio图在BOM系统里变形的另一个隐藏原因是原始画布设置不统一。同一个流程图中,有人把方框画得很大、字很小,有人一页里塞了20个节点,有人把整个图缩到50%再画,导致每个图形对象的实际坐标和尺寸五花八门。
我在给工艺部门做培训时提了三条硬性规范:
- 统一纸张方向:横向流程图用横向画布,纵向流程图用纵向画布,不要一张图里横竖混排。
- 统一字体大小:框内文字至少10号,标注文字至少9号,保证导出成位图时不会挤成一团。
- 画图时保持100%缩放:只在100%视图下编辑,避免在缩小状态下误判文字清晰度。
这三条做到位,Visio导出图片后到了TinyMCE里,比例和视觉感受基本不会再突变。别小看这些“非技术”规范,我后面做实测对比时发现,光是把字体统一了,流程图在BOM回显里的可读性就能提高一个档次。
4. TinyMCE端改造:粘贴、上传、后处理三段式拦截
4.1 基础配置:允许图片粘贴并自动上传
Visio那边源头治理好了,接下来是TinyMCE这边的工程改造。第一件事是把TinyMCE的图片粘贴能力打开,并且让图片自动上传到服务器的文件存储,而不是默认塞一个巨长的Base64字符串进BOM字段。
初始化配置里核心的几个参数如下:
tinymce.init({ selector: '#bomDescription', plugins: 'paste image lists link code', toolbar: 'undo redo | blocks | bold italic | bullist numlist | link image | code', paste_data_images: true, automatic_uploads: true, images_upload_url: '/api/bom/upload-image', images_upload_base_path: '/uploads', images_upload_handler: function (blobInfo, success, failure) { // 自定义上传逻辑,后面会详细写 }, paste_postprocess: function (pluginApi, editor, data) { // 清洗Visio粘贴带过来的脏HTML } });paste_data_images: true是允许粘贴画板里的位图数据;automatic_uploads: true是让TinyMCE拿到图片数据后自动调用上传接口;images_upload_handler是自定义上传行为的入口。
这里要提醒一句:只开前两个还不够,因为Visio粘贴过来的HTML片段里那张图片可能会被TinyMCE识别成“内嵌图片”,但它的分辨率就是Visio给的那个低质量预览图。所以还需要在paste_postprocess里做清洗和替换。
4.2 paste_postprocess:把剪贴板里的“脏内容”洗成干净的img
paste_postprocess是一个在粘贴内容被插入编辑器之前执行的钩子。我的处理思路是:
- 把粘贴内容里的VML标签全部删掉;
- 找到里面的
<img>标签; - 检查图片的宽高和来源;
- 如果是Visio带来的低分辨率预览图,干脆移除,并提示用户改用上传按钮上传高清PNG/SVG。
代码可以这样写:
paste_postprocess: function (pluginApi, editor, data) { let content = data.content; // 1. 移除VML和OLE相关标签 content = content.replace(/<v:[^>]+>[\s\S]*?<\/v:[^>]+>/gi, ''); content = content.replace(/<o:[^>]+>[\s\S]*?<\/o:[^>]+>/gi, ''); content = content.replace(/<v:[^>]+\/>/gi, ''); content = content.replace(/<o:[^>]+\/>/gi, ''); // 2. 遍历所有img标签 const tempDiv = document.createElement('div'); tempDiv.innerHTML = content; const imgs = tempDiv.querySelectorAll('img'); imgs.forEach(img => { const width = parseInt(img.getAttribute('width'), 10); const height = parseInt(img.getAttribute('height'), 10); // 3. 如果图片宽度明显小于800像素,或者src是base64且体积很小,视为低质量预览图 if (img.src.startsWith('data:image') && img.src.length < 50000) { img.remove(); editor.notificationManager.open({ text: '检测到Visio粘贴图质量过低,请使用“插入图片”按钮上传高清PNG或SVG。', type: 'warning' }); } }); data.content = tempDiv.innerHTML; }这套逻辑在项目里实测下来,能挡掉至少一半因为直接Ctrl+V导致的质量问题。但注意,它不能百分之百识别所有脏数据,比如有些Visio版本粘贴出来的预览图Base64超过50KB,但实际分辨率依然不高。所以不要过分依赖后处理,最重要的还是引导用户走上传通道。
4.3 自定义上传:让工程师用文件选择器而不是Ctrl+V
后处理只能起到拦截和提醒作用,真正可靠的做法是在编辑器里把“插入图片”这个按钮用好。我给TinyMCE配置了自定义images_upload_handler,让用户选择的PNG/SVG文件直接走上传接口,返回URL之后插入编辑器。
images_upload_handler: function (blobInfo, success, failure) { const formData = new FormData(); formData.append('file', blobInfo.blob(), blobInfo.filename()); fetch('/api/bom/upload-image', { method: 'POST', body: formData, headers: { 'X-CSRF-Token': window.csrfToken } }) .then(response => response.json()) .then(res => { if (res.code === 0) { success(res.data.url); } else { failure(res.message); } }) .catch(err => { failure(err.message || 'upload failed'); }); }后端接口我统一接收PNG和SVG两种格式。PNG用于生成缩略图,SVG用于高清展示。工程师在浏览器端看到的是<img src="/uploads/xxx.svg">,但文件上传这块并不复杂,真正复杂的是后端怎么处理SVG的安全性和尺寸校验。
5. BOM系统里的完整落地代码与交互设计
5.1 交互流程:从Visio导出到BOM保存一共四步
为了让工艺人员不再依赖Ctrl+V这种不稳定的方式,我把整个交互流程重新设计成四步:
- 工程师在Visio里把流程图另存为SVG(或高分辨率PNG);
- 在BOM系统的TinyMCE编辑器里,点击“插入图片”按钮,选择本地文件;
- 前端上传文件到BOM附件服务,拿到URL后插入编辑器,编辑器里显示图片预览;
- 工程师填写必要的流程说明文字,保存BOM节点信息。
这套流程虽然比“复制粘贴”多了一步选文件的操作,但换来的是图片稳定清晰。培训成本也不算高,关键是得给工程师讲清楚为什么不能直接复制。
5.2 前端代码:TinyMCE初始化、图片上传、尺寸校验
我贴一下当时实际使用的完整前端代码,包含图片尺寸校验和格式校验:
function initBomEditor() { tinymce.init({ selector: '#bomEditor', height: 480, menubar: false, plugins: 'paste image lists link code autosave', toolbar: 'undo redo | blocks | bold italic bullist numlist | link image | code', paste_data_images: true, automatic_uploads: true, images_upload_handler: function (blobInfo, success, failure) { const file = blobInfo.blob(); // 校验格式 const allowedTypes = ['image/png', 'image/svg+xml']; if (!allowedTypes.includes(file.type)) { failure('仅支持PNG或SVG格式的流程图'); return; } // 校验大小(10MB以内) if (file.size > 10 * 1024 * 1024) { failure('流程图大小不能超过10MB'); return; } // SVG不做像素校验,PNG检查最小宽度 if (file.type === 'image/png') { const reader = new FileReader(); reader.onload = function (e) { const img = new Image(); img.onload = function () { if (img.width < 1200) { failure('PNG图片宽度太小,请用Visio导出至少200 DPI的PNG'); return; } uploadBomImage(file).then(res => { success(res.url); }).catch(err => { failure(err.message); }); }; img.src = e.target.result; }; reader.readAsDataURL(file); } else { uploadBomImage(file).then(res => { success(res.url); }).catch(err => { failure(err.message); }); } } }); } function uploadBomImage(file) { const formData = new FormData(); formData.append('file', file); return fetch('/api/bom/upload-image', { method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrfToken() } }).then(res => res.json()).then(res => { if (res.code !== 0) { throw new Error(res.message || '上传失败'); } return res.data; }); }注意这里我在PNG上传前做了宽度校验,要求至少1200像素宽。这个值不是拍脑袋定的,而是根据BOM系统内容区大约800像素宽的渲染宽度和2倍图的清晰度要求算出来的:800×1.5或2,正好落在1200到1600之间。如果前端不加这道校验,后面审核人员在125%缩放的显示器上打开BOM详情时,图片边缘会出现肉眼可见的锯齿。
5.3 服务端处理:校验图片尺寸与SVG安全性
服务端我用的Java Spring Boot,但思路是通用的。上传接口主要做三件事:格式识别、SVG安全清洗、PNG尺寸复核。
对于PNG,我用ImageIO读取流后检查实际像素尺寸,防止前端被绕过、直接POST一个小文件上来。实际上后端必须把校验再执行一遍,因为接口是公开的,不能只依赖前端。
对于SVG,重点不是尺寸,而是安全性。Visio导出的SVG是干净的XML,但用户也可能上传一个从网上下载的、内嵌了JavaScript脚本的SVG,直接通过浏览器展示会有XSS风险。所以我写了这么一段清洗逻辑:
public String sanitizeSvg(String svgContent) { // 解析XML DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(new ByteArrayInputStream(svgContent.getBytes(StandardCharsets.UTF_8))); // 移除script、foreignObject、事件属性 NodeList scriptNodes = doc.getElementsByTagName("script"); while (scriptNodes.getLength() > 0) { Node node = scriptNodes.item(0); node.getParentNode().removeChild(node); } // 遍历所有元素,移除on开头的属性 removeEventAttributes(doc.getDocumentElement()); Transformer transformer = TransformerFactory.newInstance().newTransformer(); StringWriter writer = new StringWriter(); transformer.transform(new DOMSource(doc), new StreamResult(writer)); return writer.toString(); }你可能觉得BOM系统是内网系统,不用太担心XSS。但内网不等于绝对安全,Visio文件本身经常在企业微信、邮件里传来传去,万一某个流程图是外部供应商发来的,里面被塞了脚本,你也不知道。清洗这一步花不了多少时间,但能避免一个长期隐患。
6. 实测对比与三个典型坑
6.1 三种贴图方式的效果对照表
改造完成之后,我让工艺部的同事用了两周,同时对三种贴图方式做了对比测试,结果如下:
| 操作方式 | 编辑器内显示 | BOM审批回显 | 打印模板渲染 | 文件大小 | 推荐度 |
|---|---|---|---|---|---|
| 直接Ctrl+V粘贴 | 当时正常,细看有锯齿 | 模糊,文字发虚 | 放大后边缘明显锯齿 | 50KB左右 | 不推荐 |
| 从Visio导出PNG后上传 | 清晰,尺寸可控 | 清晰,放大到150%依然可读 | 清晰 | 1~3MB | 推荐 |
| 从Visio导出SVG后上传 | 极致清晰,任意缩放无损 | 清晰 | 矢量化渲染,打印效果最好 | 100~500KB | 最推荐 |
第二行和第三行之间,最终我们BOM系统选择了SVG作为主格式,PNG作为兜底兼容格式。原因是有些老旧的浏览器或第三方审批组件对SVG支持得不好,这时候如果系统里已经有同名的PNG文件,就自动切换成PNG展示。
6.2 坑一:Base64超长导致BOM保存接口超时
第一次改造时我没有启用automatic_uploads,觉得让用户粘图时把图片转成Base64存进BOM字段里最简单,一个字段搞定,不用额外建附件表。结果上线第一天就出问题:一张Visio导出的PNG高分辨率图可能有2MB,转成Base64之后约2.7MB,几个节点一起保存,BOM接口瞬间超时。
后来我把方案改成“图片单独上传到附件服务,BOM节点字段只保存图片URL或一个JSON数组”。这样BOM主表的字段始终很小,保存动作不受图片大小影响。如果你也在做类似系统,一定要尽早把图片和业务数据分开存,别让它们混在同一个字段里。
6.3 坑二:Visio导出SVG里带着危险标签
测试SVG上传时,我发现Visio导出的一些文件里带了<foreignObject>标签,里面包了一层HTML。这种标签在浏览器里是可以解析HTML的,存在脚本注入风险。而且Visio还可能在SVG里加入office:shape这种扩展属性,虽然浏览器会忽略,但也会让HTML渲染出现怪异的空白。
我最终的清洗规则是:除svg、g、path、rect、text、tspan、defs、line、polyline、polygon等常规绘图元素外,其余一律过滤掉;所有on*事件属性全部删除。这样处理之后,SVG还能保持Visio导出的矢量效果,但危险面大幅缩小。
6.4 坑三:透明背景在深色主题下看不见
Visio画流程图时默认是白色背景,但导出的PNG如果背景是透明的,放到BOM系统暗色主题下展示时,那些黑色线条就会和深色背景融为一体,根本看不清。
我们的解法是在上传PNG时,用服务端程序把透明通道检查一遍,如果发现整体透明度很高,就自动填充白色背景。SVG同样处理:在根节点<svg>上强制加一个style="background-color: #ffffff",这样不管前端主题怎么切换,流程图本身始终有白底,不会“隐身”。
这个细节是在一次夜班值班时被发现的,当时审批人员用的是深色主题的工单系统,打开的每张流程图几乎都是黑乎乎的。从那之后,所有上传流程图统一压白底,再也没出过这个幺蛾子。
最后再分享一个小习惯
整个项目做完,我最深的一个体会是:Visio流程图这类内容,一旦进了BOM系统,它就不是一张“图”那么简单了,而是一份要反复流转、审核、打印、归档的工程数据。对工程数据来说,稳定和清晰永远比方便更重要。所以哪怕多一步导出操作,我也更倾向于让源头交付出高分辨率PNG或SVG,而不是赌TinyMCE能把剪贴板里的混合格式处理得完美。如果你也在做类似集成,我建议先把你系统里真实使用的浏览器版本和Visio版本固定下来,做一轮全链路粘贴实测,看看剪贴板里到底带出了什么格式,再决定是走纯PNG方案还是SVG方案。这比直接抄配置更靠谱。另外一个很实用的小操作:给工艺人员做一页A4纸的“Visio导出流程图检查卡”,上面写清楚导出步骤、命名规范和最容易踩的三个坑,贴在工位旁边,比发十页操作手册管用得多。