使用psd.js将PSD转成SVG -- 基础篇(图形)

3,354 阅读7分钟

作者:佛寺   方凳雅集出品

背景

随着发展,活动会场页面的题图运营需要线上模板化,而自研的导购素材制作平台接入了海棠-创意中心,通过平台能力,将素材模板化,并且通过配置化的方式生成多种场景化,个性化的素材。但是创意中心的素材模板是基于SVG的,而会场的题图基本是基于Photoshop(PS)输出,源文件是PSD。由于SVG是面向矢量图形的标记语言,而PS是以位图处理为中心的图像处理软件,大多时候,PS无法直接导出SVG文件。

为了能让会场的题图模板接入到导购素材制作平台,同时降低设计师的使用门槛,我们需要在导购素材制作平台中实现直接将PSD转成SVG的功能,在线化的将PSD转成SVG,然后导入到创意中心,将题图模板化。

处理图形

在PS中,绘制图形一般会用到钢笔工具。



对于使用设计师而言,钢笔的运用是必备的技能,比如抠图、绘制图案、制作图标等都离不开钢笔工具。钢笔工具又可以叫路径工具,它输出的是一种矢量图,和位图不同的是,矢量图可以保证输出的图案形状不会因为缩放变形而失真。



SVG的全称又叫Scalable Vector Graphics,本身就是面向矢量图形的标记语言,所以,对于PSD中的图形路径的信息,理论上是可以映射到SVG中的。


在SVG中,用于显示图形的标签有很多:



如果是直接使用SVG输出图形的话,我们可能需要根据形状来考虑用哪个标签。比如圆形,我们会有些考虑使用circle标签,矩形,我们就会用rect,多边形,我们会用polygon,这些标签能让我们更加快速方便的绘制出想要的形状。但如果是要将PSD中的图形转换成SVG的话,就不好根据形状来选择合适的标签了,这样会使转换的实现变得复杂。


我们能不能将不同图形的绘制都统一成一种解法呢?


可以的,那就是用path标签。它是SVG基本形状中最强大的一个,提供了一套绘制语法,不仅能创建其他基本形状,还能创建更多其他形状。设计师无论是绘制什么形状,只要是用钢笔工具输出的,最终都会以路径节点的数据格式存储,通过psd.js获取到的图形信息,实际上就是一个图形路径节点的集合。


获取路径节点


使用psd.js可以通过如下方式获取到图形路径的信息。


1const vectorMask = node.get('vectorMask');
2vectorMask.parse();
3const paths = vectorMask.export();
4paths.forEach(path => {
5  console.log(path);  // 路径节点数据
6});


path是一个对象,有几个字段比较关键:

字段说明
recordType节点类型
numPoints闭合节点的数量
preceding起点控制点
anchor路径节点坐标点
leaving终点控制点


recordType


recordType记录着节点的类型,关于类型的说明可以参照这里,搜"path records",有几个需要关注的类型:

recordType说明
0起始点
1闭合的贝塞尔曲线点
2闭合的路径点,precedingleaving可以忽略
4非闭合的贝塞尔曲线的
5非闭合的路径点,precedingleaving可以忽略


numPoints


标记连续路径的节点数量,需要通过这个字段判断路径的结束位置。


preceding/anchor/leaving


preceding、anchor、leaving记录着路径节点中,三个控制点相对于PSD画布的位置信息。每个字段对应的控制点如下图:



转换路径信息


preceding、anchor、leaving这三个控制点的数据类型对象,包含两个字段horizvert,对应x和y坐标点的位置。但这里有个地方需要留意的,通常我们会用像素距离来描述某个点的位置,例如下图的点a:



点a相对画布的位置为x:10,y:60。


但是,PSD文档中的路径节点的控制点的坐标数据是两个无符号的浮点数,是相对于画布左上角原点的像素距离与画布宽高的比例,例如下图的点a:



点a相对画布的位置也可以描述为x:0.05,y:0.3。


为了更好的将PSD路径数据导出到SVG中,我们需要对这些控制点的位置进行一个转换,将比例位置转化成像素位置,同时需要将无符号浮点数转化成符号浮点数。


 1// 转化无符号浮点数
 2const signed = function(n) {
 3  let num = n;
 4  if (num > 0x8f) {
 5    num = num - 0xff - 1;
 6  }
 7
 8  return num;
 9};
10
11const getPathPosition = function(pathNode) {
12  const {
13    vert,
14    horiz
15  } = pathNode;
16
17  return {
18    x: signed(horiz),
19    y: signed(vert)
20  };
21}
22
23const parsePath = function(path, { width, height }) {
24  const {
25    preceding,
26    anchor,
27    leaving
28  } = path;
29
30  const precedingPos = this.getPathPosition(preceding);
31  const anchorPos = this.getPathPosition(anchor);
32  const leavingPos = this.getPathPosition(leaving);
33  
34  // relX 和 relY 保留了PSD中原始数据。
35  return {
36    preceding: {
37      relX: precedingPos.x,
38      relY: precedingPos.y,
39      x: Math.round(width * precedingPos.x),
40      y: Math.round(height * precedingPos.y)
41    },
42    anchor: {
43      relX: anchorPos.x,
44      relY: anchorPos.y,
45      x: Math.round(width * anchorPos.x),
46      y: Math.round(height * anchorPos.y)
47    },
48    leaving: {
49      relX: leavingPos.x,
50      relY: leavingPos.y,
51      x: Math.round(width * leavingPos.x),
52      y: Math.round(height * leavingPos.y)
53    }
54  };
55}
56
57const vectorMask = node.get('vectorMask');
58vectorMask.parse();
59const paths = vectorMask.export();
60const convertedPath = []
61paths.forEach(path => {
62  // 转换控制点的位置
63  // 这里的 document 为psd.js导出的psd文档对象
64  const { recordType, numPoints } = path;
65  const {
66    preceding,
67    anchor,
68    leaving
69  } = parsePath(path, document);  // 控制点的位置转换成了像素位置
70  
71  convertedPath.push({
72    preceding,
73    anchor,
74    leaving
75  });
76});


转换成SVG的path标签


按照path标签d属性的语法


 1const toPath = (paths) => {
 2  let head;
 3  const data = [];
 4  
 5  paths.forEach((path, index) => {
 6    const { preceding, anchor, leaving } = path;
 7    if (index < paths.length - 1) {
 8      if (index > 0) {  // 中间节点
 9        data.push(`${preceding.x}, ${preceding.y} ${anchor.x}, ${anchor.y} ${leaving.x}, ${leaving.y}`);
10      } else {  // 记录第一个节点,用于在关闭路径的时候使用
11        head = path;
12        data.push(`M ${anchor.x}, ${anchor.y} C${leaving.x}, ${leaving.y}`);
13      }
14    } else {
15      data.push(`${preceding.x}, ${preceding.y} ${anchor.x}, ${anchor.y} ${leaving.x}, ${leaving.y} ${head.preceding.x}, ${head.preceding.y} ${head.anchor.x}, ${head.anchor.y} Z`);
16    }
17  });
18  
19  return `<path d="${data.join(' ')}" />`;
20}


给图形填充颜色


如果图形填充的是纯色,可以通过如下方式获取。


 1const getFillColor = function(node) {
 2  const solidColorData = node.get('solidColor');
 3  const clr = solidColorData['Clr '];
 4
 5  return toHexColor([
 6    Math.round(clr['Rd  ']),
 7    Math.round(clr['Grn ']),
 8    Math.round(clr['Bl  '])
 9  ]);
10};


对之前的toPath方法进行一下改造。


 1const toPath = (paths, fill) => {
 2  let head;
 3  const data = [];
 4  
 5  paths.forEach((path, index) => {
 6    const { preceding, anchor, leaving } = path;
 7    if (index < paths.length - 1) {
 8      if (index > 0) {  // 中间节点
 9        data.push(`${preceding.x}, ${preceding.y} ${anchor.x}, ${anchor.y} ${leaving.x}, ${leaving.y}`);
10      } else {  // 记录第一个节点,用于在关闭路径的时候使用
11        head = path;
12        data.push(`M ${anchor.x}, ${anchor.y} C${leaving.x}, ${leaving.y}`);
13      }
14    } else {
15      data.push(`${preceding.x}, ${preceding.y} ${anchor.x}, ${anchor.y} ${leaving.x}, ${leaving.y} ${head.preceding.x}, ${head.preceding.y} ${head.anchor.x}, ${head.anchor.y} Z`);
16    }
17  });
18  
19  return `<path d="${data.join(' ')}" fill="${fill}" />`;
20}


范例


用PS制作一个只有图形的PSD文档



导出后的svg文档:


 1<?xml version="1.0" encoding="UTF-8"?>
 2<!-- generated by lst-postman -->
 3<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
 4  viewBox="0 0 750 300"
 5  enable-background="new 0 0 750 300"
 6  xml:space="preserve"
 7>
 8  
 9  <image x="0" y="0" width="750" height="300" overflow="visible" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAu4AAAEsCAYAAACc1TboAAAAAklEQVR4AewaftIAAAWHSURBVO3BMQHAMAADoBxxMcX1WC+djRxAz3dfAACAaQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvAYAAJjXAAAA8xoAAGBeAwAAzGsAAIB5DQAAMK8BAADmNQAAwLwGAACY1wAAAPMaAABgXgMAAMxrAACAeQ0AADCvAQAA5jUAAMC8BgAAmNcAAADzGgAAYF4DAADMawAAgHkNAAAwrwEAAOY1AADAvB/WUAcwL7APngAAAABJRU5ErkJggg=="></image>
10  <path d="M 246, 98 C351, 135 401, 55 422, 86 443, 117 464, 167 533, 125 602, 83 699, 115 636, 174 573, 233 408, 272 328, 245 248, 218 252, 171 144, 204 93, 220 122, 54 246, 98 Z" fill="#00ff15"></path>
11</svg>


若有收获,就赏束稻谷吧