sketch转DSL为低代码UI2Code的一个环节,在web端上传sketch设计稿文件,将sketch中画板或控件解析为低代码平台的DSL,再通过DSL引擎输出为代码,该方案目标DSL为JSON格式。
sketch转dsl核心过程如下:
- 上传sketch文件
- 解压sketch文件获取sketch文件的json数据
- 解析画板json数据,将控件实例symbolInstance对象解析替换为对应的symbolMaster
- 处理图片对象,包括将识别sketch不规则节点对象转为svg,将bitmap和背景image等进行资源存储,并用url的形式表现,用于后续DSL显示
- 节点转换,将sketch节点对象转换为dsl节点对象格式
- 布局解析与优化,解析sketch画板中每个节点的关系,实现flex布局,并优化图层的层级结构,包括图层排序、嵌套关系优化等
- 样式转换,将sketch json格式样式转为dsl格式样式
- 组件匹配,根据图层对象的名称属性中的标记信息,进行业务组件的识别,将json数据解析到业务组件的属性中
- 输出解析后的画板列表,通过dsl引擎转换为dom并进行缩略图截图显示
sketch文件上传与解压
web端通过Upload组件上传sketch文件,获取到文件file对象,使用jszip库进行sketch文件解压,通过遍历解压出的文件名识别出page json和document json,保存必要数据后,进入下一步解析。
sketch文件解压后结构如下
├── paages // sketch页面目录
└── *.json // sketch页面
├── 控件.json // 控件页面
├── images // sketch中的图片资源目录,图片对象的image._ref下为对应路径数据
├── previews // 选中画板的预览图目录
├── document.json // sketch文档的主要内容,包括控件、样式、颜色变量的引用数据和源数据
├── meta.json // 文档的基础信息
├── user.json // 用户的特定设置和信息
逻辑代码举例:
// 画板列表
const artboardList: SKLayer[] = [];
// 控件列表,
const symbolMasterList: SymbolMaster[] = [];
// 样式引用列表,如果SymbolInstance中有元素样式修改,在替换为SymbolMaster时需要从这里找到对应的样式
let layerStyles: SharedStyle[] = [];
// 颜色变量列表,用于后续颜色变量解析时,通过swatchID找到对应的颜色变量名
let sharedSwatches: Swatch[] = [];
// 前端解析sketch文件获取数据
const zip = new JSZip();
// upload上传sketch文件后的file对象
const zipInfo = await zip.loadAsync(fileInfo);
// 获取artboard list 和 symbolMaster list
for (const key in zipInfo.files) {
if (!zipInfo.files[key].dir) {
// 匹配json文件
if (/\.(json)$/.test(zipInfo.files[key].name)) {
// 以字符串形式读取json文件
const jsonString = await zip.file(zipInfo.files[key].name)?.async('string')!;
const json = JSON.parse(jsonString);
// 通过_class字段判断json文件类型
if (json?._class === 'page') {
json.layers?.forEach((layer) => {
if (
layer._class === 'artboard' ||
(layer._class === 'symbolMaster' && json.name !== '控件')
) {
artboardList.push(layer);
}
// page中也可能存在本地控件,并在其他page中引用
if (layer._class === 'symbolMaster') {
symbolMasterList.push(layer);
}
});
}
// 在document json中提取layerStyles和sharedSwatches数据以及引用的symbolMaster
if (json?._class === 'document') {
layerStyles = json.layerStyles.objects;
sharedSwatches = json.sharedSwatches.objects;
symbolMasterList.push(
...json.foreignSymbols.map((item: ForeignSymbol) => item.symbolMaster),
);
}
}
}
}
控件实例解析
1.symbolInstance实例节点转symbolMaster源节点
sketch页面中画板使用控件为控件实例,转为json时为symbolInstance实例节点,symbolInstance在json数据中不存在完整的节点json数据,通过symbolInstance的symbolID查找sketch文件中document.json和page.json下的symbolMaster控件源节点数据json数据,并进行节点替换。
2.symbolInstance实例节点属性值
- 在sketch设计时使用控件,并直接对控件实例中的节点进行样式修改后,转为json时这些样式操作会以样式引用的形式保存在symbolInstance的overrideValues属性中,可通过子节点的do_objectID查询索引样式。
- json样式处理时,相同参数传值,外层(父父级)参数值的权重高于内层(父级),特别在group编组层存在样式时,需要将样式向下层进行覆盖。
- 组件缩放比例转换
在sketch设计时,如果对控件实例最外层编组进行宽高修改,控件实例中的子图层会根据图层缩放规则进行比例拉伸,转到json时只能拿到symbolInstance的外层样式,需要根据比例拉伸对控件symbolMaster的图层样式进行等比计算。子节点宽高变化规则表现在resizingConstraint属性中。
逻辑代码举例:
// 递归处理symbolInstance
export const formatDslSymbolInstance = (
layer: AnyLayer | any,
symbolMasterList: SymbolMaster[],
layerStyles: SharedStyle[],
) => {
if (layer._class === 'symbolInstance') {
// 替换symbolInstance的layers到symbolMaster
const master = symbolMasterList.find((item: any) => item.symbolID === layer.symbolID);
const layers = master?.layers;
if (layers) {
layer.layers = JSON.parse(JSON.stringify(layers));
// 还原子节点的缩放数据
layer.layers.forEach((item: AnyLayer) => {
resizingConstraintDataFormat(item, layer, master);
});
}
// 替换symbolInstance子节点的layerStyles
formatSymbolInstanceLayerStyles(layer, layer.overrideValues, layerStyles);
}
if (layer.layers?.length > 0) {
layer.layers.forEach((item: any) => {
formatDslSymbolInstance(item, symbolMasterList, layerStyles);
});
}
};
// 递归处理symbolInstance中的layerStyles
export const formatSymbolInstanceLayerStyles = (
layer: SKLayer | any,
overrideValues: OverrideValue[],
layerStyles: SharedStyle[],
) => {
const overrideItem = overrideValues?.find((item: OverrideValue) =>
item.overrideName.includes(layer.do_objectID),
);
if (overrideItem) {
const styleValue = layerStyles?.find(
(item: SharedStyle) => item.do_objectID === overrideItem.value,
)?.value;
if (styleValue) {
layer.style = styleValue;
}
}
if (layer.layers?.length > 0) {
layer.layers.forEach((item: any) => {
formatSymbolInstanceLayerStyles(item, overrideValues, layerStyles);
});
}
};
/**
* resizingConstraint 判断宽高是否固定
* None (0): 宽度和高度都是固定的
Top (1): 顶部边缘固定
Right (2): 右侧边缘固定
Bottom (4): 底部边缘固定
Left (8): 左侧边缘固定
Width (16): 宽度固定
Height (32): 高度固定
*/
export enum ResizingConstraint {
UNSET = 0b111111,
RIGHT = 0b000001, // 1
WIDTH = 0b000010, // 2
LEFT = 0b000100, // 4
BOTTOM = 0b001000, // 8
HEIGHT = 0b010000, // 16
TOP = 0b100000, // 32
}
export const resizingConstraintData = (resizingConstraint: number) => {
const rc = resizingConstraint ^ ResizingConstraint.UNSET;
return {
top: rc & ResizingConstraint.TOP ? 'fixed' : 'auto',
right: rc & ResizingConstraint.RIGHT ? 'fixed' : 'auto',
bottom: rc & ResizingConstraint.BOTTOM ? 'fixed' : 'auto',
left: rc & ResizingConstraint.LEFT ? 'fixed' : 'auto',
width: rc & ResizingConstraint.WIDTH ? 'fixed' : 'auto',
height: rc & ResizingConstraint.HEIGHT ? 'fixed' : 'auto',
};
};
// 替换symbolInstance的frame到真实比例
export const resizingConstraintDataFormat = (
layerItem: AnyLayer,
layer: AnyLayer,
master: SymbolMaster,
) => {
const scaleW = layer.frame.width / master.frame.width;
const scaleH = layer.frame.height / master.frame.height;
if (layerItem.resizingConstraint) {
let data = resizingConstraintData(layerItem.resizingConstraint);
for (let key in data) {
switch (key) {
case 'top':
if (data[key] === 'auto') {
layerItem.frame.y = layerItem.frame.y * scaleH;
}
break;
case 'right':
case 'bottom':
break;
case 'left':
if (data[key] === 'auto') {
layerItem.frame.x = layerItem.frame.x * scaleW;
}
break;
case 'width':
if (data[key] === 'auto') {
layerItem.frame.width = layerItem.frame.width * scaleW;
}
break;
case 'height':
if (data[key] === 'auto') {
layerItem.frame.height = layerItem.frame.height * scaleH;
}
break;
}
}
}
};
图层结构重构
- 蒙版图层剪切处理
画板是最大的蒙版图层,超出画板的图层都需要进行剪切。
如果某个图层为蒙版图层,则同一个文件夹内,其后出现的图层全部为该蒙版内的子图层遍历到下一个图层后,需要逐级判断父图层是否与最近一次出现的蒙版图层(如有)有共同的“祖先”,存在共同的祖先,在导出图层为图片时始终只导出当前图层与蒙版图层的交集部分。
- 不规则图形转图片
将sketch中不规则图形转为svg格式图片,并将节点替换为图片节点。依赖paper库将不规则图层集合转为svg格式图片。
- 图层对象转DSL对象
- 过滤掉隐藏图层,判断isVisible属性删除隐藏图层。
- 图层排序,对完全无重叠的图层进行从上到下,从左到右排序,便于后续margin定位更合理。
- 根据json数据中的图层顺序和图层尺寸结构,判断是否为重叠图层,对重叠图层进行标记,后续对每层进行单独处理。(重叠图层主要用于风格弹窗等设计,判断条件为某个图层对画板宽高进行全覆盖,则该图层后的所有图层节点将属于一个新的单层结构)。
- 将图层对象转为DSL节点对象主要替换容器标签Container,文本替换为Text,图片替换为Image标签,同时初步划分节点属性,删除不必要属性。
- 嵌套结构重分组
- 删除原有的图层嵌套结构,设计过程中对图层的分组不一定完全符合前端的代码分组结构,将多层嵌套的图层冗余group分组节点删除。
- 重新判断嵌套结构,将同层的所有图层进行深度遍历扁平化,并记录其父子关系结构,遍历扁平化数据,依次判断前一个节点对象是否包含后一个节点对象,如果是,则将后一个节点对象作为前一个的子节点,从而实现新的嵌套结构。
逻辑代码举例:
// 重组树结构数据,将原来的group删掉,同层无重叠关系的图层都当作同级图层(同一添加到父图层),有重叠关系的图层,父图层包含子图层
const recombineTreeDataNew = (data: Layer[]) => {
// 每次处理一个图层元素currRecord,找到currRecord的父级为止(由于扁平化,最后一个元素应该是子图层)
const currRecord = data[data.length - 1]
if (data.length > 1) {
const preLength = data.length
for (let i = 0; i < data.length - 1; i++) {
let record = data[data.length - 2 - i]
// 判断是否包含
if (isContain(currRecord, record, data[0])) {
if (!record.children) {
record.children = []
}
// 子图层添加到children中,unshift保证子图层原来的顺序
record.children.unshift(JSON.parse(JSON.stringify(currRecord)))
// 属于父子关系,继续向前处理
data.length = data.length - 1
break
}
}
if (preLength > data.length) {
recombineTreeDataNew(data)
}
}
return data
}
- 分行分列
通过提取图层的结构特征,如位置,尺寸,类型等因素,进行特征分析,判断是否为同行元素或同列元素,对同行和同列元素增加新的父级节点包裹,便于后续margin样式结构后,删除或移动节点对其他其他节点影响最小。
- flex布局解析
- 判断节点是否只有一个子节点,同时子节点是否存在横向或纵向居中,配置节点flex布局样式,同时删除子节点居中方向上的margin样式。
- 判断节点下所有节点是否同行,再判断子节点是否存在居中和justifyContent: "space-between"等间距等特点,并进行布局样式替换。
- 同样的根据相应规则判断节点是否同列等。
- 判断同行可双层循环遍历子节点,只要每个子节点都与其他某个子节点有y轴上的相交,则代表所有子节点同行,同列同理。
组件识别
将一个sketch中设计的button识别为真实的button组件,可通过在sketch中的button最外层编组名上加一个#button#的标签。解析组件的关键就是将组件结构的每个部分的样式从json中提取处理,并填入组件的属性中。
对上面处理后的json数据进行解析,当遇到带有节点名带有#button#时,理解为这是一个button组件的开始,button组件主要包括button容器部分,和button的文字,因此可以这样理解:
- 该#button#节点就是button的容器,但该节点的子节点中可能存在一些单独的背景图层,因此和该节点宽高一样的所有容器节点都理解为button的容器部分,需要把这些容器的样式统一解析出来,作为button组件最外层的容器的样式。
- 同样#button#节点的子节点中的text文本节点,就理解为button的文本内容,并提取其样式。
- 按照组件结构的特点,从json嵌套结构中把需要的内容提取出来。
逻辑代码举例:
export const buttonMatch = (component: DSLComponentType) => {
const childrenList = childrenFlat(component);
// 查找Text标签
const textList = childrenList.filter((item: DSLComponentType) => {
return item.componentName === 'Text';
});
// 查找背景Container标签
const containerList = childrenList.filter((item: DSLComponentType) => {
return item.componentName === 'Container';
});
if(textList.length > 1){
return false;
}
component.componentName = ButtonType;
delete component.children;
if(!component.props['style']){
component.props['style'] = {};
}
if(textList[0]){
component.props['text'] = textList[0].props['text'];
if(textList[0].props['style']){
for(let key in textList[0].props['style']){
switch(key){
case 'fontSize':
case 'fontWeight':
case 'fontFamily':
case 'color':
component.props.style[key] = textList[0].props['style'][key];
default:
break;
}
}
}
}
containerList.sort((a, b) => {
return b.structure.width - a.structure.width;
});
// 查找是否存在蒙版样式需要重叠
const btnContinerList = containerList.filter((t) => {
return t.structure.width === containerList[0].structure.width;
});
// zIndex越大代表为子上层元素
btnContinerList.sort((a, b) => {
return b.structure.zIndex - a.structure.zIndex;
});
// 获取外层样式
const btnContinerStyle: Record<string, any> = btnContinerList.reduce((prev, next) => {
return {...next.props['style'], ...prev};
}, {});
if(btnContinerStyle){
for(let key in btnContinerStyle){
switch(key){
default:
component.props.style[key] = btnContinerStyle[key];
break;
}
}
}
}
参考资料: