Web端实现Sketch转DSL

1,188 阅读10分钟

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编组层存在样式时,需要将样式向下层进行覆盖。
  1. 组件缩放比例转换

在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;
      }
    }
  }
};

图层结构重构

  1. 蒙版图层剪切处理

画板是最大的蒙版图层,超出画板的图层都需要进行剪切。

如果某个图层为蒙版图层,则同一个文件夹内,其后出现的图层全部为该蒙版内的子图层遍历到下一个图层后,需要逐级判断父图层是否与最近一次出现的蒙版图层(如有)有共同的“祖先”,存在共同的祖先,在导出图层为图片时始终只导出当前图层与蒙版图层的交集部分。

  1. 不规则图形转图片

将sketch中不规则图形转为svg格式图片,并将节点替换为图片节点。依赖paper库将不规则图层集合转为svg格式图片。

  1. 图层对象转DSL对象
  • 过滤掉隐藏图层,判断isVisible属性删除隐藏图层。
  • 图层排序,对完全无重叠的图层进行从上到下,从左到右排序,便于后续margin定位更合理。
  • 根据json数据中的图层顺序和图层尺寸结构,判断是否为重叠图层,对重叠图层进行标记,后续对每层进行单独处理。(重叠图层主要用于风格弹窗等设计,判断条件为某个图层对画板宽高进行全覆盖,则该图层后的所有图层节点将属于一个新的单层结构)。
  • 将图层对象转为DSL节点对象主要替换容器标签Container,文本替换为Text,图片替换为Image标签,同时初步划分节点属性,删除不必要属性。
  1. 嵌套结构重分组
  • 删除原有的图层嵌套结构,设计过程中对图层的分组不一定完全符合前端的代码分组结构,将多层嵌套的图层冗余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
}
  1. 分行分列

通过提取图层的结构特征,如位置,尺寸,类型等因素,进行特征分析,判断是否为同行元素或同列元素,对同行和同列元素增加新的父级节点包裹,便于后续margin样式结构后,删除或移动节点对其他其他节点影响最小。

  1. 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;
            }
        }
    }
}

参考资料:

github.com/wuba/Picass…

github.com/paperjs/pap…

github.com/design-ops/…