课程 10 - 图片导入导出
图片导入导出在无限画布中是一个非常重要的功能,通过图片产物可以和其他工具打通。因此虽然目前我们的画布绘制能力还很有限,但不妨提前考虑和图片相关的问题。在这节课中你将学习到以下内容:
- 将画布内容导出成 PNG,JPEG 和 SVG 格式的图片
- 在画布中渲染图片
- 拓展 SVG 的能力,以
stroke-alignment
为例
将画布内容导出成图片
首先我们来看如何将画布内容导出成图片。Export from Figma 一文介绍了在 Figma 中如何通过切片工具将画布内容导出成包括 PNG 在内的多种格式图片。
一些基于 Canvas2D 实现的图表库也提供了保存内容到图片的功能,下图来自 Highcharts,可以看到也提供了多种格式图片的导出功能,点击后会立刻触发浏览器的下载行为:
我们希望实现如下导出功能:
- 支持多种图片格式:PNG,JPEG 和 SVG
- 支持指定裁剪区域
- 支持选择是否包含网格
为此我们为这个图片导出器设计如下的使用方式:
// 创建导出器
const exporter = new ImageExporter({
canvas, // 传入画布
});
// 获取导出图片对应的 DataURL
const canvas = await exporter.toCanvas(); // HTMLCanvasElement
const dataURL = canvas.toDataURL(); // data:...
// 触发浏览器下载
exporter.downloadImage({
dataURL,
name: 'my-file',
});
但针对不同格式的图片略有差别,下面我们先介绍 PNG / JPEG 格式图片的导出方式。
导出 PNG / JPEG
HTMLCanvasElement.toDataURL() 可以获取画布内容对应的图片 DataURI,通过 type
参数可以指定图片格式,支持 PNG / JPEG 和 WebP。
var canvas = document.getElementById('canvas');
var dataURL = canvas.toDataURL(); // 默认为 PNG
console.log(dataURL);
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
因此我们为图片导出器增加一个 toCanvas
方法。该方法用以将指定区域的画布内容绘制到额外的 HTMLCanvasElement 中,随后可以根据需要进一步加工,例如添加背景色、水印等。完整方法签名如下,值得注意的是该方法为异步,稍后我们会介绍原因:
toCanvas(options: Partial<CanvasOptions> = {}): Promise<HTMLCanvasElement>;
interface CanvasOptions {
grid: boolean;
clippingRegion: Rectangle;
beforeDrawImage: (context: CanvasRenderingContext2D) => void;
afterDrawImage: (context: CanvasRenderingContext2D) => void;
}
各配置项含义如下:
grid
是否包含网格clippingRegion
画布裁剪区域,用矩形表示beforeDrawImage
在绘制画布内容前调用,适合绘制背景颜色afterDrawImage
在绘制画布内容后调用,适合绘制水印
完整的使用示例如下:
const canvas = await exporter.toCanvas({
// 指定导出画布区域
clippingRegion: new Rectangle(
clippingRegionX,
clippingRegionY,
clippingRegionWidth,
clippingRegionHeight,
),
beforeDrawImage: (context) => {
// 绘制背景色
context.fillStyle = backgroundColor;
context.fillRect(0, 0, clippingRegionWidth, clippingRegionHeight);
},
afterDrawImage: (context) => {
// 绘制水印
context.font = '24px Times New Roman';
context.fillStyle = '#FFC82C';
context.fillText('Watermark', 20, 20);
},
});
我们在画布右上角放置了导出图片下拉菜单,如果对组件感兴趣可以参考:使用 Lit 和 Shoelace 开发 Web UI。
$icCanvas = call(() => {
return document.createElement('ic-canvas-lesson10');
});
call(() => {
const { Canvas, Circle } = Lesson10;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas.parentElement.style.position = 'relative';
$icCanvas.parentElement.appendChild($stats);
$icCanvas.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 100; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const circle = new Circle({
cx: Math.random() * 600,
cy: Math.random() * 400,
r: Math.random() * 40,
fill,
});
canvas.appendChild(circle);
circle.addEventListener('pointerenter', () => {
circle.fill = 'red';
});
circle.addEventListener('pointerleave', () => {
circle.fill = fill;
});
}
});
$icCanvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});
现在让我们来回答为何 toCanvas
方法要设计成异步。由于 WebGL / WebGPU 使用 SwapChain 双缓冲机制,在创建上下文时,preserveDrawingBuffer 的默认值为 false
,因此需要确保调用 toDataURL
时渲染没有被清除(例如调用 gl.clear()
),否则就会得到一张空白图片。我们在插件系统的 endFrame
钩子中添加如下逻辑,一旦接收到导出命令,就立刻在当前帧结束前保存画布内容,以防下一帧到来时被清空,这会导致该行为变成异步。
hooks.endFrame.tap(() => {
this.#device.endFrame();
// capture here since we don't preserve drawing buffer
if (this.#enableCapture && this.#resolveCapturePromise) {
const { type, encoderOptions } = this.#captureOptions;
const dataURL = (
this.#swapChain.getCanvas() as HTMLCanvasElement
).toDataURL(type, encoderOptions);
this.#resolveCapturePromise(dataURL);
this.#enableCapture = false;
this.#captureOptions = undefined;
this.#resolveCapturePromise = undefined;
}
});
另外可以选择导出图片是否包含 网格。暂时我们没有使用到裁剪以及水印等后续加工相关的功能。由于实现类似 Figma 的“切片”功能需要配合框选交互,后续实现时会一同介绍。现在让我们回到另一种特殊格式的图片。
导出 SVG
相比位图,矢量图的优势体现在:
- 无限缩放不失真。这意味着它们可以被无限放大或缩小而不会失去清晰度,非常适合需要多种分辨率的场合。
- 可编辑性。SVG 是文本文件,可以使用任何文本编辑器进行编辑,便于修改图形的属性和样式。
- 对于复杂图形具有更小的文件大小。
因此设计工具一定都会提供对于这种格式的转换支持。对于我们的无限画布,可以将问题转换成:如何将场景图序列化,包括其中每一个节点的绘制属性、变换等。至于序列化的格式,除了 JSON,由于我们的 2D 图形的绘制属性设计本身就大量参考 SVG 实现,因此导出成 SVG 就十分自然了。
有趣的是,在 Three.js 中也提供了 toJSON 对场景的当前状态(包括对象、变换、材质等)保存成 JSON-Object-Scene-format-4。甚至还包括一个 SVGRenderer 能在有限条件下(无复杂 shading、阴影)尽可能渲染 3D 图形。
从图形到序列化节点
从场景图的根节点开始遍历,递归对子元素调用。对于 transform
这样拥有复杂结构值(position/scale/rotation
)的属性需要进一步处理:
function serializeNode(node: Shape): SerializedNode {
const [type, attributes] = typeofShape(node);
const data: SerializedNode = {
type,
attributes,
};
data.attributes.transform = serializeTransform(node.transform);
data.children = node.children.map(serializeNode);
return data;
}
以下面的 Circle 为例,我们得到了它的序列化对象:
serializedCircle = call(() => {
const { Circle, serializeNode } = Lesson10;
const circle = new Circle({
cx: 100,
cy: 100,
r: 50,
fill: 'red',
});
circle.transform.position.x = 100;
return serializeNode(circle);
});
通过反序列化方法就可以将它导入画布,deserializeNode
根据序列化节点的 type
属性创建对应图形,为绘图属性赋值:
canvas2 = (async () => {
const { Canvas } = Lesson10;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas;
})();
call(async () => {
const { deserializeNode } = Lesson10;
const circle = await deserializeNode(serializedCircle);
canvas2.root.appendChild(circle);
return canvas2.getDOM();
});
整个画布的序列化和反序列化方法只要对根节点应用即可,当然事件监听器是无法被序列化的:
const json = JSON.stringify(serializeNode(canvas.root)); // {}
canvas.root = (await deserializeNode(JSON.parse(json))) as Group;
从序列化节点到 SVGElement
此时将序列化后的节点转换为 SVG Element 就很容易了。我们增加如下工具方法:
export function toSVGElement(node: SerializedNode): SVGElement;
大部分属性例如 fill / stroke / opacity
都是 SVG 的同名属性,因此可以直接使用 setAttribute 进行赋值,但仍有一些特殊的属性需要特殊处理,例如:
transform
我们使用了@pixi/math
中的Transform
对象,需要它的position / rotation / scale
转换成matrix()
transform-origin
对应transform
中的pivot
属性innerShadow
并不存在 SVG 同名属性,需要使用 filter 实现outerShadow
同上
下面的例子展示了一个序列化后的圆转换成 <circle>
的效果,为了能在 HTML 页面中展示需要嵌入 <svg> 中,它的尺寸和画布保持一致:
call(() => {
const { toSVGElement } = Lesson10;
const $circle = toSVGElement(serializedCircle);
const $svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
$svg.setAttribute('width', '200');
$svg.setAttribute('height', '200');
$svg.appendChild($circle);
return $svg;
});
从 SVGElement 到序列化节点
很自然的,我们也需要一个从 SVGElement 到序列化节点的转换方法:
export function fromSVGElement(element: SVGElement): SerializedNode;
但需要注意这种转换并不完全可逆,例如后续我们会介绍到的 StrokeAlignment 实现 中,一个 Circle 对应了两个 <circle>
。在 Figma 中也是如此,下图展示了导入一个此前导出的 Ellipse 效果,可以看出两个图形并不完全一致。
有趣的是,@pixi-essentials/svg 也试图将 SVG 元素转换成 Pixi.js 中的图形并绘制,详见 Vector rendering of SVG content with PixiJS。
SVG 中的场景图
还有一点需要注意,在我们的场景图中任意图形都可以添加子节点,但 SVG 中只有 <g>
才可以添加子元素,除此之外例如 <circle>
是无法拥有子元素的。解决办法也很简单,对于拥有子节点的非 Group 元素,生成 SVG 时在外面套一个 <g>
,将原本应用在本身的 transform
应用在它上面。假设后续我们支持了渲染文本,一个拥有文本子节点的 Circle 对应的 SVG 如下:
<g transform="matrix(1,0,0,0,1,0)">
<circle cx="100" cy="100" r="100" fill="red" />
<text />
</g>
inner & outerShadow
参考 Creating inner shadow in svg
导出作为填充的图片
虽然我们还没有介绍填充图片的实现,但如果直接将 fill
属性的 Image 值序列化将得到如下结果:
<circle fill="[object ImageBitmap]" />
这显然是无法正常展示的,我们需要将 Image 对象序列化成 DataURL,只要将图片绘制到 <canvas>
中,就可以利用上一节我们介绍过的 toDataURL
方法。参考 Convert ImageBitmap to Blob,我首先尝试了 ImageBitmapRenderingContext,ImageBitmap 可以在不阻塞主线程的情况下异步解码图像,这有助于提高应用的性能和响应性。
async function imageBitmapToURL(bmp: ImageBitmap) {
const canvas = document.createElement('canvas');
// resize it to the size of our ImageBitmap
canvas.width = bmp.width;
canvas.height = bmp.height;
// get a bitmaprenderer context
const ctx = canvas.getContext('bitmaprenderer');
ctx.transferFromImageBitmap(bmp);
const blob = await new Promise<Blob>((res) => canvas.toBlob(res));
return canvas.toDataURL();
}
但很不幸我们得到了如下报错。原因是我们已经使用了这个 ImageBitmap 创建了纹理,就无法再将它的控制权转移给新的 <canvas>
了。
CAUTION
The input ImageBitmap has been detached
因此我们只能使用常规的 drawImage
方式将图片绘制到 <canvas>
中。
绘制网格
最后来看如何使用 SVG 实现网格。参考 How to draw grid using HTML5 and canvas or SVG,我们使用 <pattern>
平铺实现:对于直线网格使用一大一小两组;对于圆点网格分别在“砖块”的四个角上各放置一个圆形:
然后在绘制网格逻辑处加入判断:非截图模式下保持不变,截图模式下仅当开启包含网格选项时才绘制:
hooks.beginFrame.tap(() => {
if (
!this.#enableCapture ||
(this.#enableCapture && this.#captureOptions.grid)
) {
this.#grid.render(this.#device, this.#renderPass, this.#uniformBuffer);
}
});
导出 PDF
现在像素和矢量图都有了,如果还想导出成 PDF 可以使用 jsPDF,它提供了添加图片的 API,限于篇幅这里就不介绍了。
下面让我们来看另一个话题,如何在画布中渲染一张图片。
在画布中渲染图片
在 WebGL / WebGPU 中通常需要加载图片并将其作为纹理使用,由于加载不同类型的资源是一个复杂的异步过程,大部分渲染引擎都会提供一个资源加载器。通常还会支持除不同类型图片之外的其他数据类型,例如音频、JSON、glTF 等。下面是 PIXI Assets 的使用例子:
import { Sprite, Assets } from 'pixi.js';
// load the texture we need
const texture = await Assets.load('bunny.png');
// This creates a texture from a 'bunny.png' image
const bunny = new Sprite(texture);
那么如何实现一个资源加载器呢?
图片加载器
loaders.gl 针对不同类型的资源提供了一系列加载器,例如:
- 可视化应用中常用的 JSON、CSV、GeoJSON、地理信息瓦片等
- 3D 模型格式例如 glTF 等
- 各种压缩纹理格式,使用 CompressedTextureLoader 在 WebWorker 中进行加载解析
这些加载器为上层应用的开发提供了极大便利,我们可以直接使用 ImageLoader,它支持这些图片格式:PNG, JPEG, GIF, WEBP, AVIF, BMP, SVG。使用方式如下,这里也能看出 loaders.gl 的设计思路,@loaders.gl/core
保证了 API 调用方式的统一以及不同类型加载器的可扩展性:
import { ImageLoader } from '@loaders.gl/images';
import { load } from '@loaders.gl/core';
const image = await load(url, ImageLoader, options);
除了直接设置 fill
属性,在反序列化时也需要将 DataURL 还原成 Image 对象:
async function deserializeNode(data: SerializedNode) {
// data:image/png:base64...
if (fill && isString(fill) && isDataUrl(fill)) {
shape.fill = (await load(fill, ImageLoader)) as ImageBitmap;
}
}
API 设计
回到我们的 API 设计部分,我们当然可以仿照之前的 Circle / Ellipse / Rect 为图片新增一种图形,对应 SVG 的 <image>:
const image = new Image({
x: 0,
y: 0,
width: 100,
height: 100,
src: 'data:image...',
});
但仔细想想 Image 应该拥有 Rect 的全部能力,例如描边、圆角、阴影等等。有趣的是在 Figma 中如果我们选择向画布中插入一张原尺寸 1920 * 1920 的图片并导出成 SVG,会得到一个 <rect>
元素,它的结构如下(省略部分属性值),fill
属性引用了一个 <pattern>,间接使用了图片平铺填充:
<svg>
<rect width="500" height="500" fill="url(#pattern0_2442_3)" />
<defs>
<pattern id="pattern0_2442_3">
<use xlink:href="#image0_2442_3"
<!-- 0.000520833 = 1 / 1920 -->
transform="matrix(0.000520833 0 0 0.000527058 0 -0.0059761)" />
</pattern>
<image
id="image0_2442_3"
width="1920"
height="1920"
xlink:href="data:image/png;base64,iVBO..."
/>
</defs>
</svg>
这给了我们一点启发,图片没必要以图形的形式单独存在,只要其他图形的 fill
属性支持贴图就好了,这样 Circle / Rect 等都可以使用图片作为填充。试想我们需要实现一个带描边的圆形图标需求,按照原本的设计需要一个 Image 图形搭配类似 <clipPath> 才能实现,现在只需要在已有 Circle 图形上填充图片:
circle.fill = image;
circle.stroke = 'black';
实现
因此第一步我们将 fill
支持的类型从颜色字符串扩展到更多纹理来源:
export interface IRenderable {
fill: string;
fill: string | TexImageSource;
}
type TexImageSource =
| ImageBitmap
| ImageData
| HTMLImageElement
| HTMLCanvasElement
| HTMLVideoElement
| OffscreenCanvas
| VideoFrame;
在顶点数据中需要一个字段表示是否使用了纹理,如果使用就对纹理进行采样,这里的 SAMPLER_2D()
并非标准 GLSL 语法,而是我们自定义的标记,用于在 Shader 编译阶段替换成 GLSL100 / GLSL300 / WGSL 的采样语法。另外,目前纹理是上传的图片,后续还可以支持使用 Canvas2D API 创建的渐变例如 createLinearGradient:
// vert
out vec2 v_Uv;
v_Uv = (a_FragCoord * radius / size + 1.0) / 2.0;
// frag
in vec2 v_Uv;
uniform sampler2D u_Texture;
if (useFillImage) {
fillColor = texture(SAMPLER_2D(u_Texture), v_Uv);
}
使用了 uniform
会打破我们之前的合批处理逻辑。Inside PixiJS: Batch Rendering System 一文介绍了 Pixi.js 的 BatchRenderer 实现逻辑,从下面运行时编译的 Shader 模版可以看出,最大可以同时支持一组 %count%
个采样器,每个实例通过顶点数据 aTextureId
在采样器组中进行选择。
// Shader template in Pixi.js BatchRenderer
// vert
attribute int aTextureId;
varying int vTextureId;
vTextureId = aTextureId;
// frag
uniform sampler2D uSamplers[%count%];
varying int vTextureId;
简单起见,我们将合批逻辑简化为:
fill
取值为颜色字符串与 Image 的无法合批fill
取值为不同 Image 的无法合批
因此下面的三个 Circle 会在同一批绘制。
$icCanvas3 = call(() => {
return document.createElement('ic-canvas-lesson10');
});
call(() => {
const { Canvas, Circle } = Lesson10;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas3.parentElement.style.position = 'relative';
$icCanvas3.parentElement.appendChild($stats);
$icCanvas3.addEventListener('ic-ready', async (e) => {
const image = await Utils.loadImage(
'https://infinitecanvas.cc/canvas.png',
);
const canvas = e.detail;
const circle1 = new Circle({
cx: 200,
cy: 200,
r: 50,
fill: image,
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
strokeAlignment: 'inner',
});
canvas.appendChild(circle1);
const circle2 = new Circle({
cx: 320,
cy: 200,
r: 50,
fill: image,
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
});
canvas.appendChild(circle2);
const circle3 = new Circle({
cx: 460,
cy: 200,
r: 50,
fill: image,
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
strokeAlignment: 'outer',
});
canvas.appendChild(circle3);
});
$icCanvas3.addEventListener('ic-frame', (e) => {
stats.update();
});
});
增加缓存
之前我们一直没有考虑类似 Program、Bindings、Sampler 等这些 GPU 对象的缓存问题。为此我们增加一个资源缓存管理器以实现复用,依据资源类型分别实现命中逻辑。以 Sampler 为例,当 SamplerDescriptor
中的属性完全一致时就会命中缓存,比较逻辑在 samplerDescriptorEquals
中。
import { samplerDescriptorEquals } from '@antv/g-device-api';
export class RenderCache {
device: Device;
private samplerCache = new HashMap<SamplerDescriptor, Sampler>(
samplerDescriptorEquals,
nullHashFunc,
);
createSampler(descriptor: SamplerDescriptor): Sampler {
// 优先从缓存中取
let sampler = this.samplerCache.get(descriptor);
if (sampler === null) {
// 未命中,创建并添加缓存
sampler = this.device.createSampler(descriptor);
this.samplerCache.add(descriptor, sampler);
}
return sampler;
}
}
增强 SVG: Stroke alignment
最后我们来引入一个有趣的话题。我们可以实现目前 SVG 规范还不支持的特性。
先来感受下 SVG 中 opacity
stroke-opacity
和 fill-opacity
的区别。下图左边的圆应用了 opacity="0.5"
,右边应用了 fill-opacity="0.5" stroke-opacity="0.5"
。可以看出 stroke 描边有一半在圆内,一半在圆外:
在 Figma 中对应的 Stroke 位置的取值为 Center
,其他可选值包括 Inside
和 Outside
,下图分别展示了这三种取值的效果。在 SVG 中名为 stroke-alignment
,但目前停留在草案阶段,详见 Specifying stroke alignment。
我们为所有图形增加 strokeAlignment
属性:
export interface IRenderable {
strokeAlignment: 'center' | 'inner' | 'outer';
}
渲染部分实现
在 Shader 部分的实现只需要区分这三种取值,按不同方式混合填充和描边色:
if (strokeAlignment < 0.5) { // center
d1 = distance + strokeWidth;
d2 = distance + strokeWidth / 2.0;
color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1);
color = mix_border_inside(strokeColor, color, d2);
} else if (strokeAlignment < 1.5) { // inner
d1 = distance + strokeWidth;
d2 = distance;
color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1);
color = mix_border_inside(strokeColor, color, d2);
} else if (strokeAlignment < 2.5) { // outer
d2 = distance + strokeWidth;
color = mix_border_inside(strokeColor, color, d2); // No need to use fillColor at all
}
下面是我们的实现效果,可以看出渲染效果和 Figma 一致:
$icCanvas2 = call(() => {
return document.createElement('ic-canvas-lesson10');
});
call(() => {
const { Canvas, Circle } = Lesson10;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas2.parentElement.style.position = 'relative';
$icCanvas2.parentElement.appendChild($stats);
$icCanvas2.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
const circle1 = new Circle({
cx: 200,
cy: 200,
r: 50,
fill: '#F67676',
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
strokeAlignment: 'inner',
});
canvas.appendChild(circle1);
const circle2 = new Circle({
cx: 320,
cy: 200,
r: 50,
fill: '#F67676',
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
});
canvas.appendChild(circle2);
const circle3 = new Circle({
cx: 460,
cy: 200,
r: 50,
fill: '#F67676',
stroke: 'black',
strokeWidth: 20,
strokeOpacity: 0.5,
strokeAlignment: 'outer',
});
canvas.appendChild(circle3);
});
$icCanvas2.addEventListener('ic-frame', (e) => {
stats.update();
});
});
在计算渲染包围盒和拾取判定时,也需要考虑该属性。下面的函数反映了不同取值下,描边应该从图形本身向外延伸多少距离。
function strokeOffset(
strokeAlignment: 'center' | 'inner' | 'outer',
strokeWidth: number,
) {
if (strokeAlignment === 'center') {
return strokeWidth / 2;
} else if (strokeAlignment === 'inner') {
return 0;
} else if (strokeAlignment === 'outer') {
return strokeWidth;
}
}
导出 SVG 部分实现
正如前文提到的那样,SVG 目前还不支持 stroke-alignment
,因此目前只能通过 hack 手段模拟。如果图形比较简单,完全可以分两次绘制填充和描边。下面为 Figma 对 stroke-alignment: 'inner'
的导出结果,也采用了这种方式:
<circle cx="200" cy="200" r="200" fill="#F67676" />
<circle
cx="200"
cy="200"
r="160"
stroke="black"
stroke-opacity="0.5"
stroke-width="100"
/>
除此之外,Figma 官网文档在 StrokeAlign in Figma widget 一文中还给出了另一种思路,不需要创建两个同类元素。How to simulate stroke-align (stroke-alignment) in SVG 尝试了这一思路,放大描边宽度至原来的两倍,配合 clipPath 和 mask 将多余部分剔除掉:
Inside and outside stroke are actually implemented by doubling the stroke weight and masking the stroke by the fill. This means inside-aligned stroke will never draw strokes outside the fill and outside-aligned stroke will never draw strokes inside the fill.
出于实现简单考虑,我们选择第一种方式,创建两个同类元素分别绘制填充和描边,可以在上面的示例中尝试导出 SVG。
随着画布功能的丰富,我们有必要引入测试来验证导入导出功能是否正常、渲染效果是否正确、UI 组件是否可交互。在下一节中我们将介绍它。