SVG 导出 PNG 总是缺字、发糊、图片空白:前端图表导出的六个坑
发布时间:2026/9/27 13:01:55来源:尧图网络
流程图、思维导图、各种图表只要是 SVG 渲染的迟早会遇到一个需求导出成 PNG。网上的标准答案只有四行把 SVG 序列化成字符串塞进img画到 canvas 上toBlob导出。constsvgTextnewXMLSerializer().serializeToString(svgEl);img.srcdata:image/svgxml;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 编码consturldata:image/svgxml;charsetutf-8,encodeURIComponent(svgText);更推荐的是第三种直接用 Blob URL没有编码开销SVG 很大的时候也不会生成一个几 MB 的字符串constblobnewBlob([svgText],{type:image/svgxml;charsetutf-8});consturlURL.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){constcsgetComputedStyle(source);conststyleSVG_STYLE_PROPS.map((p)${p}:${cs.getPropertyValue(p)}).join(;);target.setAttribute(style,style);for(leti0;isource.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 内部的styleasyncfunctionembedFont(svgClone,family,fontUrl){constbufawait(awaitfetch(fontUrl)).arrayBuffer();constb64arrayBufferToBase64(buf);conststyledocument.createElementNS(http://www.w3.org/2000/svg,style);style.textContentfont-face{font-family:${family};src:url(data:font/woff2;base64,${b64}) format(woff2);};svgClone.insertBefore(style,svgClone.firstChild);}functionarrayBufferToBase64(buf){letbinary;constbytesnewUint8Array(buf);constchunk0x8000;for(leti0;ibytes.length;ichunk){binaryString.fromCharCode.apply(null,bytes.subarray(i,ichunk));}returnbtoa(binary);}这里有个现实问题中文字体文件动辄几 MB全嵌进去导出会明显变慢。如果只是为了导出要么接受系统字体要么对字体做子集化只保留图里出现的字。坑四节点里的图片是空白的有的流程图节点里放了图标或图片image hrefhttps://...。导出后这些位置是空的。原因同上外部资源一律不加载。解决办法同样是先把图片转成 data URL 再替换hrefasyncfunctioninlineImages(svgClone){constimagessvgClone.querySelectorAll(image);awaitPromise.all([...images].map(async(el){consthrefel.getAttribute(href)||el.getAttribute(xlink:href);if(!href||href.startsWith(data:))return;constblobawait(awaitfetch(href,{mode:cors})).blob();constdataUrlawaitnewPromise((resolve){constreadernewFileReader();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 倍屏上看就等于被放大了一倍。解决方法是按倍数放大 canvasconstscaleMath.max(2,window.devicePixelRatio||1);canvas.widthwidth*scale;canvas.heightheight*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处理画出来就是一个缩小的、被裁掉的图。所以克隆之后要显式补上constbboxsvgEl.getBBox();constpadding20;constwidthMath.ceil(bbox.widthpadding*2);constheightMath.ceil(bbox.heightpadding*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_AREA16_000_000;letscaleMath.max(2,window.devicePixelRatio||1);if(width*height*scale*scaleMAX_AREA){scaleMath.sqrt(MAX_AREA/(width*height));}真的特别大的图PNG 本身就不是好格式。这种情况我会建议用户导出 SVG矢量图没有尺寸上限放多大都清晰。完整函数把上面几步串起来asyncfunctionexportSvgToPng(svgEl,{padding20,background#ffffff}{}){// 1. 克隆不动页面上的原图constsvgClonesvgEl.cloneNode(true);inlineStyles(svgEl,svgClone);awaitinlineImages(svgClone);// 2. 按图本身的范围算尺寸constbboxsvgEl.getBBox();constwidthMath.ceil(bbox.widthpadding*2);constheightMath.ceil(bbox.heightpadding*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 的中文问题constsvgTextnewXMLSerializer().serializeToString(svgClone);consturlURL.createObjectURL(newBlob([svgText],{type:image/svgxml;charsetutf-8}));try{constimgnewImage();img.srcurl;awaitimg.decode();// 4. 算倍数防止超出 canvas 上限constMAX_AREA16_000_000;letscaleMath.max(2,window.devicePixelRatio||1);if(width*height*scale*scaleMAX_AREA){scaleMath.sqrt(MAX_AREA/(width*height));}constcanvasdocument.createElement(canvas);canvas.widthMath.floor(width*scale);canvas.heightMath.floor(height*scale);constctxcanvas.getContext(2d);ctx.scale(scale,scale);// 5. PNG 默认透明背景放进深色主题的文档里会看不清先铺底色ctx.fillStylebackground;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);}}调用constblobawaitexportSvgToPng(document.querySelector(#diagram svg));constadocument.createElement(a);a.hrefURL.createObjectURL(blob);a.downloaddiagram.png;a.click();URL.revokeObjectURL(a.href);最后那个铺底色是我后来加上的。PNG 默认透明背景用户把图贴进深色主题的笔记软件里黑色的线条和文字就直接消失在背景里了。这个问题报上来的时候我愣了好一会儿。这套做法的局限foreignObject里的 HTML 是个雷区。有些图表库在 SVG 里用foreignObject嵌 HTML 来做富文本节点这部分内联样式要按 HTML 的规则处理上面的SVG_STYLE_PROPS不够用不同浏览器的表现也不完全一致。能不用foreignObject就别用。字体嵌入的成本不低。中文 Web 字体不做子集化的话导出一次要拉几 MB。跨域图片没有纯前端解法服务器不给 CORS 头就只能代理。Canvas 画出来的文字和页面上可能有细微差别字距、基线位置偶尔会差一两个像素对精度要求很高的场景要注意。如果你的场景里图不会太大、不用 Web 字体、没有外部图片那其实前面三个坑都不会遇到四行代码加上尺寸和倍数处理就够了。先看清自己的图里有什么再决定要补哪几步。
网站建设高端定制企业官网