流程图、思维导图、各种图表,只要是 SVG 渲染的,迟早会遇到一个需求:导出成 PNG。
网上的标准答案只有四行:把 SVG 序列化成字符串,塞进<img>,画到 canvas 上,toBlob导出。
constsvgText=newXMLSerializer().serializeToString(svgEl);img.src='data:image/svg+xml;base64,'+btoa(svgText);ctx.drawImage(img,0,0);canvas.toBlob(save);这四行在 demo 里能跑。放到真实页面里,基本每一行都会出事:中文报错、样式全丢、字体变成宋体、图片一片空白、导出来发糊、大图直接导出失败。
我在给 forxi.cn 做流程图导出的时候把这些坑都踩了一遍,下面按出现的先后顺序说,最后给一个完整可用的函数。文中代码都是为这篇文章单独写的示意实现。
坑一:btoa遇到中文直接抛异常
DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.btoa只接受 Latin1 字符,节点文字里只要有一个中文就炸。
有两种改法。一种是先转 UTF-8 字节再 base64,比较绕;另一种干脆不用 base64,用 URL 编码:
consturl='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svgText);更推荐的是第三种,直接用 Blob URL,没有编码开销,SVG 很大的时候也不会生成一个几 MB 的字符串:
constblob=newBlob([svgText],{type:'image/svg+xml;charset=utf-8'});consturl=URL.createObjectURL(blob);// 用完记得 URL.revokeObjectURL(url)坑二:导出来样式全丢了,线条是黑的、填充也是黑的
页面上看着好好的图,导出来所有矩形都是黑色填充,连线又粗又黑。
原因是:页面上的样式很多写在外部 CSS 里(.node rect { fill: #eef4ff }这种),而SVG 作为<img>加载时是一个独立文档,根本看不到页面的样式表。序列化出来的字符串里只有结构,没有样式,于是全部回退到默认值:fill默认黑色。
解决办法是在序列化之前,把用到的样式内联到每个元素上。不要把getComputedStyle的几百个属性全抄一遍(字符串会膨胀几十倍),只抄 SVG 渲染真正用得到的那些:
constSVG_STYLE_PROPS=['fill','fill-opacity','stroke','stroke-width','stroke-dasharray','stroke-opacity','opacity','font-family','font-size','font-weight','text-anchor','dominant-baseline','visibility','display',];functioninlineStyles(source,target){constcs=getComputedStyle(source);conststyle=SVG_STYLE_PROPS.map((p)=>`${p}:${cs.getPropertyValue(p)}`).join(';');target.setAttribute('style',style);for(leti=0;i<source.children.length;i++){inlineStyles(source.children[i],target.children[i]);}}注意这里是原节点读样式、克隆节点写样式。不要直接改页面上的原节点,否则导出一次,页面上的图就被写满了内联 style,之后切换主题就不生效了。
坑三:字体变成了宋体
样式内联了,font-family也写上了,导出来还是宋体(或者系统默认字体)。
还是那个原因:SVG 作为图片加载时,不能访问任何外部资源。页面通过@font-face加载的 Web 字体,在这个独立文档里不存在。
如果只用系统字体(PingFang SC、Microsoft YaHei这些),问题不大,只要font-family写对,系统里有就能用。如果用了 Web 字体,要把字体文件转成 base64,写进 SVG 内部的<style>:
asyncfunctionembedFont(svgClone,family,fontUrl){constbuf=await(awaitfetch(fontUrl)).arrayBuffer();constb64=arrayBufferToBase64(buf);conststyle=document.createElementNS('http://www.w3.org/2000/svg','style');style.textContent=`@font-face{font-family:'${family}';src:url(data:font/woff2;base64,${b64}) format('woff2');}`;svgClone.insertBefore(style,svgClone.firstChild);}functionarrayBufferToBase64(buf){letbinary='';constbytes=newUint8Array(buf);constchunk=0x8000;for(leti=0;i<bytes.length;i+=chunk){binary+=String.fromCharCode.apply(null,bytes.subarray(i,i+chunk));}returnbtoa(binary);}这里有个现实问题:中文字体文件动辄几 MB,全嵌进去,导出会明显变慢。如果只是为了导出,要么接受系统字体,要么对字体做子集化,只保留图里出现的字。
坑四:节点里的图片是空白的
有的流程图节点里放了图标或图片(<image href="https://...">)。导出后这些位置是空的。
原因同上,外部资源一律不加载。解决办法同样是先把图片转成 data URL 再替换href:
asyncfunctioninlineImages(svgClone){constimages=svgClone.querySelectorAll('image');awaitPromise.all([...images].map(async(el)=>{consthref=el.getAttribute('href')||el.getAttribute('xlink:href');if(!href||href.startsWith('data:'))return;constblob=await(awaitfetch(href,{mode:'cors'})).blob();constdataUrl=awaitnewPromise((resolve)=>{constreader=newFileReader();reader.onload=()=>resolve(reader.result);reader.readAsDataURL(blob);});el.setAttribute('href',dataUrl);}));}fetch这一步要求图片服务器返回 CORS 头。如果图片不支持跨域,这条路就走不通,只能让后端代理一下,或者导出时跳过这张图。
顺带一提,如果你绕过这一步,直接把跨域图片drawImage到 canvas 上,canvas 会被"污染"(tainted),之后toBlob会抛SecurityError。这个错误信息很不直观,第一次遇到很容易以为是别的问题。
坑五:导出来发糊
在 Retina 屏幕上看,页面里的图很清晰,导出的 PNG 一放大全是锯齿。
原因是 canvas 默认按 CSS 像素创建,而屏幕是 2 倍甚至 3 倍像素密度。一个 800×600 的图,按 800×600 的 canvas 导出,在 2 倍屏上看就等于被放大了一倍。
解决方法是按倍数放大 canvas:
constscale=Math.max(2,window.devicePixelRatio||1);canvas.width=width*scale;canvas.height=height*scale;ctx.scale(scale,scale);ctx.drawImage(img,0,0,width,height);这里我直接给了一个最低 2 倍,因为导出的图经常会被放进 PPT、文档里再放大,按 1 倍屏导出的图放进 PPT 里基本都是糊的。
还有一个相关的坑:SVG 根节点要有明确的width、height和viewBox。如果 SVG 是靠 CSS 撑满容器的(width: 100%),序列化后没有尺寸信息,<img>加载它会按默认尺寸(常见是 300×150)处理,画出来就是一个缩小的、被裁掉的图。所以克隆之后要显式补上:
constbbox=svgEl.getBBox();constpadding=20;constwidth=Math.ceil(bbox.width+padding*2);constheight=Math.ceil(bbox.height+padding*2);svgClone.setAttribute('width',width);svgClone.setAttribute('height',height);svgClone.setAttribute('viewBox',`${bbox.x-padding}${bbox.y-padding}${width}${height}`);用getBBox()而不是容器尺寸,还顺便解决了另一个问题:导出的是图本身的范围,而不是当前可视区域。用户把图拖到画布角落再导出,也不会导出一大片空白。
坑六:图太大,导出直接失败
节点多了以后,图的尺寸很容易到几千像素。再乘个 2 倍、3 倍,canvas 就超出浏览器的上限了。
超限的表现各浏览器不一样,有的是toBlob回调拿到null,有的是画出来一片空白,不会抛一个清晰的错误。移动端 Safari 的限制尤其紧,单个 canvas 的面积上限大约是 1677 万像素(4096×4096)。
所以放大倍数不能写死,要根据面积算一个上限:
constMAX_AREA=16_000_000;letscale=Math.max(2,window.devicePixelRatio||1);if(width*height*scale*scale>MAX_AREA){scale=Math.sqrt(MAX_AREA/(width*height));}真的特别大的图,PNG 本身就不是好格式。这种情况我会建议用户导出 SVG,矢量图没有尺寸上限,放多大都清晰。
完整函数
把上面几步串起来:
asyncfunctionexportSvgToPng(svgEl,{padding=20,background='#ffffff'}={}){// 1. 克隆,不动页面上的原图constsvgClone=svgEl.cloneNode(true);inlineStyles(svgEl,svgClone);awaitinlineImages(svgClone);// 2. 按图本身的范围算尺寸constbbox=svgEl.getBBox();constwidth=Math.ceil(bbox.width+padding*2);constheight=Math.ceil(bbox.height+padding*2);svgClone.setAttribute('width',width);svgClone.setAttribute('height',height);svgClone.setAttribute('viewBox',`${bbox.x-padding}${bbox.y-padding}${width}${height}`);svgClone.setAttribute('xmlns','http://www.w3.org/2000/svg');// 3. 序列化成 Blob URL,避开 btoa 的中文问题constsvgText=newXMLSerializer().serializeToString(svgClone);consturl=URL.createObjectURL(newBlob([svgText],{type:'image/svg+xml;charset=utf-8'}));try{constimg=newImage();img.src=url;awaitimg.decode();// 4. 算倍数,防止超出 canvas 上限constMAX_AREA=16_000_000;letscale=Math.max(2,window.devicePixelRatio||1);if(width*height*scale*scale>MAX_AREA){scale=Math.sqrt(MAX_AREA/(width*height));}constcanvas=document.createElement('canvas');canvas.width=Math.floor(width*scale);canvas.height=Math.floor(height*scale);constctx=canvas.getContext('2d');ctx.scale(scale,scale);// 5. PNG 默认透明背景,放进深色主题的文档里会看不清,先铺底色ctx.fillStyle=background;ctx.fillRect(0,0,width,height);ctx.drawImage(img,0,0,width,height);returnawaitnewPromise((resolve,reject)=>{canvas.toBlob((blob)=>(blob?resolve(blob):reject(newError('导出失败,图可能太大'))),'image/png');});}finally{URL.revokeObjectURL(url);}}调用:
constblob=awaitexportSvgToPng(document.querySelector('#diagram svg'));consta=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='diagram.png';a.click();URL.revokeObjectURL(a.href);最后那个"铺底色"是我后来加上的。PNG 默认透明背景,用户把图贴进深色主题的笔记软件里,黑色的线条和文字就直接消失在背景里了。这个问题报上来的时候我愣了好一会儿。
这套做法的局限
foreignObject里的 HTML 是个雷区。有些图表库在 SVG 里用foreignObject嵌 HTML 来做富文本节点,这部分内联样式要按 HTML 的规则处理,上面的SVG_STYLE_PROPS不够用,不同浏览器的表现也不完全一致。能不用foreignObject就别用。- 字体嵌入的成本不低。中文 Web 字体不做子集化的话,导出一次要拉几 MB。
- 跨域图片没有纯前端解法,服务器不给 CORS 头就只能代理。
- Canvas 画出来的文字和页面上可能有细微差别,字距、基线位置偶尔会差一两个像素,对精度要求很高的场景要注意。
如果你的场景里图不会太大、不用 Web 字体、没有外部图片,那其实前面三个坑都不会遇到,四行代码加上尺寸和倍数处理就够了。先看清自己的图里有什么,再决定要补哪几步。