SVG+Canvas绘制矩形--为什么四条边看起来不一样

768 阅读6分钟

前言

白板项目中,需要提供绘制图形的功能,开发时发现绘制出来的矩形的四个边总是粗细不一致,找来找去踩了很多坑,耗费了很多时间,完成后总结梳理一下。

基本实现步骤

考虑后续可能增加用户上传SVG绘制自定义图形功能,所以采用SVG+Canvas方案。

将背景canvas命名为bigCanvas,将图形所在的canvas命名为shapeCanves,绘制的图形所在的最小矩形包围盒称为shapeBox。

简化流程: Pasted image 20220701163242.png

1.初始化shapeCanvas

// 图形的最小矩形包围盒
type ShapeBox = {
    x: number; // px
    y: number;
    width: number;
    height: number;
    borderWidth: number;
    type: 'rect' | 'circle' ;
}

let dpr = window.devicePixelRatio || (window as any).webkitDevicePixelRatio || (window as any).mozDevicePixelRatio || 1;
// dpr = Math.min(dpr,1)
const width = Math.round((shapeBox as ShapeBox).width); // 图形元素宽度
const height = Math.round((shapeBox as ShapeBox).height); // 图形元素高度

const shapeCanvas = document.createElement('canvas') as HTMLCanvasElement;
const ctx = shapeCanvas.getContext('2d') as CanvasRenderingContext2D;
shapeCanvas.width = width * dpr;
shapeCanvas.height = height * dpr;
shapeCanvas.style.width = `${width}px`;
shapeCanvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);

(1)含小数值px的宽高

处理方式:const width = Math.floor((shapeBox as ShapeBox).width);

浏览器对于含小数值px的DOM元素的宽高的解析存在差异,为了避免各种差异带来的麻烦,在涉及到渲染的地方,统一对x/y/width/height做Math.round四舍五入处理。(后续代码中的x、y、width、height如无特殊说明,都是采用四舍五入处理后的值)

采用处理方案为:

x: round(x)  
y: round(y)  
width: round(width) 
height: round(height)

为了简单采用了上述方案,其实更合理的处理方案如下: 参考trac.webkit.org/wiki/Layout…

  • 方案1:pixelSnappedIntRect
x: round(x)  
y: round(y)  
maxX: round(x +  width)
maxY: round(y + height)
width: round(x +  width) - round(x)
height: round(y + height) - round(y)
  • 方案2:enclosingIntRect
x: floor(x)  
y: floor(y)  
right: ceil(x +  width)
bottom: ceil(y + height)
width: ceil(x +  width) - round(x)
height: ceil(y + height) - round(bottom)

Pasted image 20220701110850.png

(2)canvas自身大小、canvas CSS大小

首先理解canvas本身的宽高(bigCanvas.width、bigCanvas.height)、canvas元素的CSS宽高(bigCanvas.style.width、bigCanvas.style.height)的区别 <canvas>自身的width、height与<img>元素相同,当没有设置宽度和高度的时候,canvas 会初始化宽度为 300 像素和高度为 150 像素,可以看做内宽高(张鑫旭-CSS世界)。元素的CSS宽高可以看多外宽高。 绘制时canvas会伸缩以适应它的外宽高大小,如果 CSS 的尺寸与初始画布的比例不一致,它会出现扭曲。 1656646772939.png 为了避免出现拉伸扭曲,我们要将canvas本身的宽高与canvas元素的css宽高设置一致。即:

bigCanvas.width = width;
bigCanvas.height = height;
bigCanvas.style.width = `${width}px`;
bigCanvas.style.height = `${height}px`;

(3)dpr

可是对比实现方案,差了一个dpr?

概念:dpr 当前显示设备的物理像素分辨率与CSS 像素分辨率之比。 此值也可以解释为像素大小的比率:一个 CSS 像素的大小与一个物理像素的大小。 简单来说,它告诉浏览器应使用多少屏幕实际像素来绘制单个 CSS 像素。

Canvas本身的宽高(bigCanvas.width、bigCanvas.height)是与物理像素对应的;而CSS宽高是与CSS像素对应,绘制一个CSS像素需要dpr个物理像素的;即不拉伸要求:当CSS宽高为1*1时,canvas宽高dpr*dpr,下图以dpr=2为例,填满一个CSS像素点,需要2*2个物理像素点。 Pasted image 20220701115640.png 所以绘制时不拉伸,需要:

canvas本身的宽高 = canvas的CSS宽高* dpr,而我们shapeBox的宽高都是指CSS宽高,所以宽高计算应调整为:

bigCanvas.width = width * dpr;
bigCanvas.height = height * dpr;
bigCanvas.style.width = `${width}px`;
bigCanvas.style.height = `${height}px`;

画布大小放大dpr倍后,在画布中的绘制也要调整,如原来画一个长度为1的线,现在要画长度为1*dpr,对每一次绘制进行*dpr太麻烦了,canvas提供了CanvasRenderingContext2D.scale()方法,可以根据 x 水平方向和 y 垂直方向,对整个坐标系进行缩放。如,如果我们将 2 作为缩放因子,最终的单位会变成 2 像素,并且形状的尺寸会变成原来的两倍。x,y方向两个参数都传入dpr即可,即:

ctx.scale(dpr, dpr);

2.拼SVG数据

不了解SVG的,可以先阅读阮一峰的SVG入门文章

需要注意的一点是,SVG描边是以路径为中心线绘制的,在上面的例子里,路径是粉红色的,描边是黑色的。如你所见,路径的每一侧都有均匀分布的描边。(来源:MDNPasted image 20220701145632.png

所以在svg中,rect绘制路径比图形本身的边缘要向内缩小绘制粗细(即strokeWidth)的一半,SVG绘制路径的width,height,x,y都要根据strokeWidth调整,而不是直接用shapeBox的值。示意图如下。 Pasted image 20220701154246.png

以矩形为例,对应的生成SVG的代码:

const rectWidth = width - strokeWidth; 
const rectHeight = height - strokeWidth;
const rectX = strokeWidth / 2;
const rectY = strokeWidth / 2;

const svgData += `<svg xmlns="http://www.w3.org/2000/svg" width="${width}px" height="${height}px">
<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" style="fill:#FFFFFF;stroke-width:${strokeWidth};stroke:#000000;"/>
</svg>`;

3.将SVG绘制到shapeCanvas上

有很多绘制方式,这里采用Canvg绘制

const canvg = Canvg.fromString(ctx!, svgData);
canvg.start({
	ignoreAnimation: true,
	ignoreDimensions: true,
	ignoreMouse: true,
});

4.将shapeCanvas绘制到bigCanvas上

// 省略bigCanvas创建过程
bigCanvasCtx.save();
const translateX = x + width / 2;
const translateY = y + height / 2;
bigCanvas.translate(translateX, translateY);
bigCanvas.rotate((angle * Math.PI) / 180);
bigCanvas.translate(-translateX, -translateY);
bigCanvas.drawImage(canvas, x, y, width, height);
bigCanvas.restore();

其他:Canvas中绘制1px线模糊

还有一个canvas中绘制1px线模糊的常见问题,原因canvas是路径为中心线绘制,宽度分布在中心线,当宽度没有占满物理像素时,浏览器会自动把宽度漫满,并且颜色变淡,看起来就会模糊。网上大都采用了让画线位置卡在0.5位置上,如1.5,2.5,10.5...;但是当绘制宽度2px线时,调整前不会模糊,调整后反而会模糊了。示意图如下。 Pasted image 20220701162937.png 因为项目中的线宽strokeWidth基础值是2px,并且图形进行缩放时,线宽会跟随缩放,所以对于本项目来说,采用浏览器的策略就可以了。即便有时出现模糊也是可以接受的,只要保证相同宽度的线视觉效果一致即可,即保证四条边同时卡在整数上或者同时卡在x.5上,甚至在n和(1-n)上(0<n<1)上(如x.2和x.8)。 Pasted image 20220701170441.png 可以发现我们四条边的中心位置分别是。

x: round(x) + strokeWidth/2 
y: round(y) + strokeWidth/2 
maxX: round(x) + round(width) - strokeWidth/2 
maxY: round(height) + round(y) - strokeWidth/2

若strokeWidth/2 的小数部分为n,则x,y的值为m.n(m为整数),maxX,maxY的值为m.(1-0.n),四条边视觉效果相同。

总结:矩形四条边看起来粗细不一致的可能原因

  1. 宽度、高度是小数
  2. Canvas的css样式宽高与Canvas自身宽高不一致
  3. SVG绘制路径位置不当

参考:

  1. 🔥关于 canvas 模糊的问题(高清图解)
  2. Canvas__MDN
  3. trac.webkit.org/wiki/Layout…