前端图形架构设计:AI生成设计稿落地实践

系列

《前端图形引擎架构设计:基于ECS模式的可扩展渲染系统》

前言

最近在做 2D 图形渲染相关的项目。由于真实数据还不完善,我一直使用手写 mock 数据进行测试,但这样很难覆盖所有业务场景。于是我开始思考------是否可以借助 AI 自动生成测试数据?更进一步,如果让 AI 直接生成设计稿,再交给渲染系统呈现,不就能同时提升开发效率和产品质量吗?如今不少产品已经开始接入 AI 提效,这件事值得折腾一下。

效果图:

左侧为设计,右侧为html

总体来说,渲染还原度还是挺高的。

AI 方案探索

最初的方案是让 AI 直接生成我系统使用的 DSL 数据结构,比如:

ts 复制代码
interface DSLParams {
  position: Position;
  size: Size;
  color: Color;
  lineWidth?: LineWidth;
  id: string;
  selected?: Selected;
  eventQueue?: { type: string; event: MouseEvent }[];
  type: string;
  rotation: Rotation;
  font: Font;
  name?: string;
  img?: Img;
  zIndex: ZIndex;
  scale?: Scale;
  polygon?: Polygon;
  radius?: Radius;
}

理论上可行,但实际效果总是差强人意------坐标偏差、数据错误、样式混乱,结果往往与预期相差甚远。 反倒是让 AI 生成 HTML/CSS 时,视觉效果相当不错。 旧效果:

旧提示词:

于是我想到: 既然 AI 在生成网页展示上表现更好,那么先生成 HTML,再将其转换为 DSL,是不是就能大幅提升还原度和使用体验?

AI 设计成图方案说明(优化版)

为了提升设计稿生成效率,我采用了"AI 生成 HTML → 自动转换为 DSL → 引擎渲染"的方式来实现 AI 设计能力。

具体思路是:让 AI 输出一份只有 HTML 和 CSS、没有任何 JavaScript 的静态页面。该页面的结构和样式表达完整的视觉效果。随后通过解析 HTML DOM Tree,将标签、样式、层级等信息映射为内部 DSL 描述,实现设计稿的结构化转换与渲染。

这种方式最大的优势在于:

  • AI 擅长生成可视化良好的 HTML/CSS,即所见即所得
  • DSL 转换自动化,避免 AI 直接生成复杂结构时的偏差
  • 可预览、可校验、可回溯,工程可控性高
  • 未来可支持反向生成 HTML,构建完整设计工具链

最终实现:

AI 负责创意表达 → 系统负责数据准确性*

从而显著提升设计能力和生产效率。

flowchart LR A[用户需求输入] --> B[AI 生成 HTML+CSS] B --> C[DOM Tree 解析器] C --> D[样式与布局分析] D --> E[HTML 转 DSL 映射引擎] E --> F[DSL 渲染引擎] F --> G[最终可视化效果]

技术方案

AI mode

首先创建AI model,这里使用的是字节的Eino框架,目前还没有用到框架的任何内容,比如工作流,工具tools,mcp等等,如果后期需要方便迭代。

go 复制代码
package ai

func initModel(modelName string) (*dto.AiModel, error) {
	ctx := context.Background()
	if len(modelName) == 0 {
		modelName = "deepseek-r1"
	}
	chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
		BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
		Model:   modelName,                   // 使用的模型版本
		APIKey:  os.Getenv("OPENAI_API_KEY"), // OpenAI API 密钥
	})

	if err != nil {
		return nil, fmt.Errorf("failed to initialize chat model %s: %w", modelName, err)
	}

	if chatModel == nil {
		return nil, fmt.Errorf("chat model %s is nil", modelName)
	}

	tpl := DslDesignTpl()
	return &dto.AiModel{
		ChatModel: *chatModel,
		ChatTpl:   tpl,
	}, nil
}
// 获取模型名称
func getModelByName() (*dto.AiHandler, error) {
	models := make(map[string]dto.AiModel)

	AIModels := []string{
		"deepseek-r1",
		"deepseek-v3",
		"deepseek-r1-0528",
		"Moonshot-Kimi-K2-Instruct",
		"qwen3-max",
	}

	for _, name := range AIModels {
		model, err := initModel(name)
		if err != nil {
			fmt.Printf("Failed to initialize model %s: %v\n", name, err)
			continue // 跳过失败的模型,继续初始化其他模型
		}
		if model != nil {
			models[name] = *model
		}
	}

	if len(models) == 0 {
		return nil, fmt.Errorf("no AI models were successfully initialized")
	}

	return &dto.AiHandler{Models: models}, nil
}

func Provide(contanier *dig.Container) {
	contanier.Provide(getModelByName)
}

定义提示词prompt

md 复制代码
你是一名{role}请根据用户的文字描述生成 一个完整的静态网页,页面必须满足以下所有条件:
⸻
## 基本规则
	###.	布局固定尺寸(非自适应)
	-	如果用户没有说明,默认页面宽度为 375px(移动端)。
	-	若用户指定为 PC 设计,则宽度固定为 1440px。
	-	页面可垂直滚动,但不随窗口大小变化,不可伸缩。
	-	所有布局、元素大小、间距、字体大小,必须全部使用 px 单位。
	### 2.	禁止使用 JavaScript
	-	不得包含任何 <script> 标签。
	-	不得包含任何内联事件(如 onclick、onchange、onsubmit 等)。
	-	不允许依赖 JS 的组件、库或交互逻辑。
	-	所有视觉与交互效果,仅允许使用纯 CSS(如 :hover、:focus、:checked、details 元素等有限方案)。
	### 3.	禁止响应式与媒体查询
	-	不允许出现任何 @media 或 @container 规则。
	-	所有元素按固定像素位置与大小排布,不考虑窗口缩放。
	### 4.	HTML 结构要求
	-	使用语义化标签:<header>、<main>、<section>、<article>、<footer> 等。
	-	模块划分清晰,层级合理,并附带简短注释。
	-	不使用任何 JS 相关属性或依赖。
	### 5.	CSS 组织方式
	-	所有样式必须放在 <style> 标签内(位于 <head> 中)。
	-	禁止使用外部 CSS 文件或字体文件。
	-	允许使用 CSS 变量定义颜色与通用参数:

:root (
  --bg: #ffffff;
  --text: #222222;
  --primary: #007bff;
  --radius: 8px;
)
	- 字体与字号必须使用像素,例如:
   font-size: 16px;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
	### 6.	视觉与排版要求
	-	所有边距、间距、宽度、高度都使用 px,不得使用 %、rem、em、vw、vh 等。
	-	默认背景为白色(除非用户要求)。
	-	主色、辅助色可由用户提供,也可使用默认蓝色 (#007bff)。
	-	圆角、阴影、字体、线条宽度也必须是像素值。
	### 7.	可访问性与规范
	-	所有图片需包含 alt。
	-	所有表单控件必须有 <label>。
	-	禁止花哨字体与动画,确保清晰度与一致性。
	### 8.	输出格式要求
	-	返回一个完整的 HTML 文件:包含 <!doctype html>、<head>(带 <meta>、<title>、<style>)与 <body>。
	-	页面注释清晰,模块划分合理。
	-	在文件开头用注释说明页面设计宽度、主色与风格说明。
	-	所有单位严格为 px。
   ### 9. 图标尽可能采用svg,
   - 如果svg不满足,可以采用图片替代,如果图片不存在,可以使用矩形或者圆形代替.
	 - svg内必须且只有一个path来描述图标形状,不能有其他元素,比如circle,rect等
	 - svg的path必须有fill属性,不能没有fill属性
   ### 10. 不要使用伪类元素
   - 使用div或者其他元素模拟,不要使用伪类
	 ### 11. css要求
   - 不要使用position
	 - 不要使用渐变色,使用rgb或者Hex Color
	 ### 12. DOM要求
	 - 所有元素都必须有明确的宽高,不能使用自动布局
	 - 所有表单元素必须有label,并且label与表单元素关联
	 - 所有图片必须有alt属性
	 ### 13. 禁止使用表格布局
	 - 不允许使用<table>、<tr>、<td>等标签进行布局
	 ### 15. 禁止使用CSS框架
	 - 不允许使用Bootstrap、Tailwind等CSS框架
	 ### 文字说明
	 - 正文字体大小不得小于12px,标题字体大小不得小于16px
	 - 行高等于字体大小
	 ### 17. 输入框要求
	 - 输入框要通过div等元素模拟,不能使用<input>、<textarea>等原生表单元素
	 ### 文字拆行(核心新增规则 ------ 禁止自动换行,必须生成块级"行")
优化后的提示词如下,它更清晰地定义了约束、计算逻辑和输出格式,同时明确了\*\*"只有纯文本段落才应用此规则"\*\*的范围。

-----

## 文本分行渲染指令(核心规则:禁止自动换行,必须生成块级行)

**目标:** 在生成 HTML 文本内容时,**禁止依赖浏览器自动换行**。模型必须根据以下规则,将纯文本内容(非按钮、非表单元素等)分割成一系列具有固定宽度的块级行元素(例如 <div class="line">...</div>),以模拟精确的文本布局。

### 1\. 约束与计算参数

**输入假设:**

  * **容器可用宽度** $W$ (px)。
  * **字体大小/行高** $S$ (px)。

**CSS 约定:**

  * 假设:**单行高度** $H = S$ (px)。
  * 假设:**中文字符宽度** $\approx S$ (px)。
  * 假设:**英文字符(含空格)宽度** $\approx 0.6 \times S$ (px)。

**行最大容量计算:**

  * **中文/全角字符最大数量** $N_(ch) = \text(floor)(W / S)$。
  * **英文/拉丁字母最大数量** $N_(en) = \text(floor)(W / (0.6 \times S))$。

### 2\. 文本拆分算法(模型必须严格执行)

1.  **分行单位:** 文本内容必须被拆分成多个 <div class="line"> 块,每个块代表一个渲染行。
2.  **单词优先原则(针对拉丁文本):** 拆分时应优先在**空格**处断行。**不允许在单词内部(word-break)断行**,除非单词本身长度超过了 $N_(en)$ 限制。
3.  **中文字符拆分:** 连续的中文字符流,按 $N_(ch)$ 的上限进行分割。
4.  **混合文本处理:**
      * 优先在空格处断行。
      * 对于连续的中文字符块,按 $N_(ch)$ 计算。
      * 对于连续的拉丁单词/字符块,按 $N_(en)$ 计算。
5.  **超长内容处理(强制拆分):**
      * 若单个**单词**(拉丁文)或连续**字符流**(中文/混合)的长度超出当前行最大容量,**允许**在该单词/字符流内部进行强制字符级拆分。
      * 如果进行强制拆分,应尽量在断裂处使用连字符(-)连接(仅针对拉丁文,中文直接断开)。

### 3\. HTML 结构与输出要求

**范围限制:** 本规则**仅适用于纯文本内容**(例如文章段落、简介、描述等)。**不适用于**按钮文本、表单标签、导航链接等非连续文本块。

**生成的 HTML 结构示例(模型输出必须以此为模板):**
(假设 $W=335$px, $S=18$px)

<div class="text-block" aria-label="文本块描述">
  <div class="line">这是第一行中文内容</div>
  <div class="line">这是第二行中文内容</div>
  <div class="line">An example English line that fits</div>
  <div class="line">A-very-long-word-that-needs-break-</div>
</div>

### 4\. 强制 CSS 样式(模型输出必须包含此部分)

**模型必须在输出中附带以下严格使用 px 单位的 CSS 规则:**
(此处的 $W$ 和 $S$ 必须替换为实际计算值,例如 $W=335$, $S=18$)


.text-block (
  width: Wpx; /* 容器宽度 */
  height: auto; /* 高度可按 [行数 * S] 计算后写死,或保留 auto */
  /* 其他块级样式 */
)
.line (
  width: Wpx;
  height: Spx;         /* 行高等于字体大小(px) */
  font-size: Spx;
  line-height: Spx;
  white-space: nowrap; /* 确保单行内不自动换行 */
  overflow: hidden;    /* 确保超出的内容被隐藏,由生成器控制拆行 */
  margin-top: 10px;    /* 示例:行间距 */
)
⸻
##  用户输入格式(示例)
用户需求:
页面类型:移动端个人名片页
页面宽度:375px
主色:#4B7BEC
模块:头像区、个人简介、联系方式、底部版权
风格:极简、白底蓝色按钮
⸻
## 输出示例规范(指示给模型使用)
- 页宽固定:width: 375px; margin: 0 auto;
- 主容器中所有元素都用像素值控制,如:
.profile 
  width: 335px;
  height: 120px;
  margin: 20px auto;
  border-radius: 12px;
  padding: 16px;

   - 禁止出现:
   @media
   %
   rem
   em
   vw
   vh
   script
   onclick
   animation
   transition
	-	所有单位必须为 px。
### 输出格式
- html要用markdown包裹
--
现在,请按照用户要求输出HTML

定义数据流

go 复制代码
// ...
requestCtx := c.Request.Context()

	messages, err := template.Format(requestCtx, map[string]any{
		"role":         "专业资深前端开发专家",
		"chat_history": []*schema.Message{},
	})
	if err != nil {
		logger.Error(err.Error() + "test")
		errs.FailWithJSON(c, err)
		return
	}
	for _, v := range *body.Messages {
		if v.Role == "user" {
			messages = append(messages, schema.UserMessage(v.Content))
		}
		if v.Role == "assistant" {
			messages = append(messages, schema.AssistantMessage(v.Content, nil))
		}
	}
	streamResult, err := chatModel.Stream(requestCtx, messages)
	if err != nil {
		logger.Error(err.Error())
		errs.FailWithJSON(c, err)
		return
	}

	h.service.ReportStream(c, requestCtx, streamResult)

DOM tree解析器 help

当用户点击画面中的应用时,将得到的HTML渲染到iframe上,通过获取的ifame的body,获取页面上所有的domstyle属性。

ts 复制代码
  public init(html: string) {
    return new Promise<DSL[]>((resolve) => {
      const iframe = (this.iframe = document.createElement("iframe"));
      // iframe.style.visibility = "hidden";
      iframe.style.width = "370px";
      iframe.style.height = "800px";
      iframe.style.position = "fixed";
      iframe.style.right = "425px";
      iframe.style.top = "0px";
      iframe.style.border = "1px solid #ccc";
      iframe.onload = () => {
        if (iframe.contentDocument) {
          iframe.contentDocument.open();
          iframe.contentDocument.write(html);

          iframe.contentDocument.close();
          this.transform(iframe);
          console.log(this.dsls, "this.dsls");
          resolve(this.dsls);
        }
      };
      document.body.appendChild(iframe);
    });
  }

递归获取style,解析成dsl,按照以下顺序执行

flowchart TD A[输入 HTML] --> B[写入 iframe 渲染] B --> C[解析 body 及子节点] C --> D[extract style & geometry] D --> E[type 判断 + 属性分类] E --> F[生成 DSL 数据] F --> G[递归转换子元素] G --> H[返回 DSL 数组]

总体步骤是这样,但是细化下来就有许多需要尽可能的达到还原效果所做的各种兼容。

判断元素类型

比如说对于html来说,页面中的所有元素都是矩形,圆形也是矩形的圆角设置得到。那么就需要在排除img、文字、svg等等内容外,其他都属于矩形,设置个默认rect

ts 复制代码
 const rect = style.dom.getBoundingClientRect();
      const dom = style.dom as HTMLElement;
      this.id += 1;
      let type = "rect";
      let src = "";
      let path = "";
      let fillColor = style.backgroundColor;
      let strokeColor = "";
      switch (dom.nodeType) {
        case Node.ELEMENT_NODE: {
          const tagName = dom.tagName.toLowerCase();
          type = this.isCircle(style, rect) ? "ellipse" : type;
          type = dom.children.length === 0 && dom.childNodes[0] ? "rect" : type;
          type = style.domChildType === "text" ? "text" : type;
          if (tagName === "img") {
            type = "img";
            src = (dom as HTMLImageElement).src;
          }
          if (tagName === "svg") {
            type = "img";
            const svgData = this.getSvgData(dom, style);
            fillColor = svgData.fill;
            strokeColor = svgData.stroke;
            path = svgData.path;
          }
          break;
        }
      }
圆形

需要根据元素的圆角程度来判断,当前圆角是否满足圆形还是部分圆角。

ts 复制代码
/**
   * 判断元素是否为圆形
   * @param style 元素的样式
   * @param rect 元素的边界矩形
   * @returns 如果是圆形返回true,否则返回false
   */
  isCircle(style: Style, rect: DOMRect): boolean {
    // 判断dom是否是圆形
    const width = rect.width;
    const height = rect.height;
    let type = "rect";
    let borderRadius = 0;
    const borderRadiusValue = style.borderTopLeftRadius;

    if (borderRadiusValue.includes("%")) {
      const percentage = parseFloat(borderRadiusValue) / 100;
      borderRadius = Math.min(width, height) * percentage;
    } else {
      borderRadius = parseFloat(borderRadiusValue) || 0;
    }

    if (borderRadius > 0 && Math.abs(width - height) < 1) {
      const minSize = Math.min(width, height);
      if (Math.abs(borderRadius - minSize / 2) < 2) {
        type = "ellipse";
      }
    }
    return type === "ellipse";
  }

当然在画布渲染方面也需要更改,比如原来渲染矩形是通过ctx.strokeRect来渲染一个完整的矩形,但是由于画圆角的api对浏览器支持不好,所以需要手动去画矩形,也就是多边形,判断不同的边,确定圆角的大小来画圆角。

ts 复制代码
    // 上边
    if (lw.top > 0 && sc.top !== "transparent") {
      const offset = this.getPixelOffset(lw.top);
      ctx.beginPath();
      ctx.lineWidth = lw.top;
      ctx.strokeStyle = sc.top;
      ctx.moveTo(r.lt, offset);
      ctx.lineTo(width - r.rt, offset);
      ctx.stroke();
    }
    // 右边
    if (lw.right > 0 && sc.right !== "transparent") {
      const offset = this.getPixelOffset(lw.right);
      ctx.beginPath();
      ctx.lineWidth = lw.right;
      ctx.strokeStyle = sc.right;
      ctx.moveTo(width - offset, r.rt);
      ctx.lineTo(width - offset, height - r.rb);
      ctx.stroke();
    }
    // 下边
    if (lw.bottom > 0 && sc.bottom !== "transparent") {
      const offset = this.getPixelOffset(lw.bottom);
      ctx.beginPath();
      ctx.lineWidth = lw.bottom;
      ctx.strokeStyle = sc.bottom;
      ctx.moveTo(width - r.rb, height - offset);
      ctx.lineTo(r.lb, height - offset);
      ctx.stroke();
    }
    // 左边
    if (lw.left > 0 && sc.left !== "transparent") {
      const offset = this.getPixelOffset(lw.left);
      ctx.beginPath();
      ctx.lineWidth = lw.left;
      ctx.strokeStyle = sc.left;
      ctx.moveTo(offset, height - r.lb);
      ctx.lineTo(offset, r.lt);
      ctx.stroke();
    }

    // 四个圆角
    const drawCorner = (
      cx: number,
      cy: number,
      rad: number,
      startAngle: number,
      endAngle: number,
      color: string,
      lw: number
    ) => {
      if (rad > 0 && lw > 0 && color !== "transparent") {
        ctx.beginPath();
        ctx.lineWidth = lw;
        ctx.strokeStyle = color;
        ctx.arc(cx, cy, rad, startAngle, endAngle);
        ctx.stroke();
      }
    };

    drawCorner(r.lt, r.lt, r.lt, Math.PI, -Math.PI / 2, sc.top, lw.top); // 左上
    drawCorner(width - r.rt, r.rt, r.rt, -Math.PI / 2, 0, sc.right, lw.right); // 右上
    drawCorner(
      width - r.rb,
      height - r.rb,
      r.rb,
      0,
      Math.PI / 2,
      sc.bottom,
      lw.bottom
    ); // 右下
    drawCorner(
      r.lb,
      height - r.lb,
      r.lb,
      Math.PI / 2,
      Math.PI,
      sc.left,
      lw.left
    ); // 左下
  }
坐标Position和大小
ts 复制代码
  // 不需要计算margin,因为getBoundingClientRect已经包含margin了
    const position = {
      x: rect.left + window.scrollX,
      y: rect.top + window.scrollY,
    };
    // paddingtop和paddingleft不能计算进去,因为宽高不包括padding
    const size = {
      width:
        rect.width +
        (parseFloat(style.borderLeftWidth) || 0) +
        (parseFloat(style.borderRightWidth) || 0),
      height:
        rect.height +
        (parseFloat(style.borderTopWidth) || 0) +
        (parseFloat(style.borderBottomWidth) || 0),
    };

需要注意的是,因为使用getBoundingClientRect,所以marginpadding不需要计算。

边框颜色
yaml 复制代码
    // 根据css,判断文字的垂直对齐方式
    const color = {
      fillColor,
      strokeColor: this.getBorderColor(style, strokeColor),
      strokeTColor: style.borderTopColor,
      strokeBColor: style.borderBottomColor,
      strokeLColor: style.borderLeftColor,
      strokeRColor: style.borderRightColor,
    };

需要分别设置边框颜色,如果取到的颜色只有一个,则设置默认颜色。

边框宽度
ts 复制代码
      const lineWidth = {
      value: defaultBorderWidth,
      top: hasBorder ? parseFloat(style["borderTopWidth"]) || 0 : 0,
      bottom: hasBorder ? parseFloat(style["borderBottomWidth"]) || 0 : 0,
      left: hasBorder ? parseFloat(style["borderLeftWidth"]) || 0 : 0,
      right: hasBorder ? parseFloat(style["borderRightWidth"]) || 0 : 0,
    };

分别获取样式style中,边框的颜色。

svg的渲染

svg中有很多属性,对于一个图标来说,可能由不同的图标组成的,但是目前只取一个svgpath读取。 将path转换成对应的canvas画布数据。

ts 复制代码
/**
   * 获取svg的path,fill,stroke
   * @param dom
   * @param style
   * @returns
   */
  getSvgData(
    dom: HTMLElement,
    style: Style
  ): { path: string; fill: string; stroke: string } {
    let path = "";
    let fillColor = "";
    let strokeColor = "";
    const pathElement = dom.querySelector("path");

    if (pathElement) {
      path = pathElement.getAttribute("d") || "";

      const pathElementFill = pathElement.getAttribute("fill") || "";
      const pathElementStroke = pathElement.getAttribute("stroke") || "";
      const pathStyle = window.getComputedStyle(pathElement);

      fillColor = this.getValidColor(pathElementFill, pathStyle.fill);
      strokeColor = this.getValidColor(pathElementStroke, pathStyle.stroke);
    } else {
      fillColor = style.fill || "transparent";
      strokeColor = this.getValidColor("", style.stroke);
    }
    return { path, fill: fillColor, stroke: strokeColor };
  }

svg中的颜色fillstroke可能来自于css和属性,比如说path上的属性fill权重要大于css的样式权重,所以读取的时候,需要注意按照权重读取样式,并统一处理返回到dsl中。

文字

由于文字也涉及的点比较多,比如说居中,垂直居中,靠左,靠右,上对齐,下对齐等等,需要统一处理。 需要考虑css的布局样式,是弹性盒还是文本布局等。

ts 复制代码
/**
   * 获取文本对齐方式
   * @param style 元素的样式
   * @returns 文本对齐方式
   */
  getTextAlignment(style: CSSStyleDeclaration) {
    const display = style.display;
    const alignItems = style.alignItems;
    const justifyContent = style.justifyContent;
    const textAlign = style.textAlign;

    const height = parseFloat(style.height);
    const lineHeight = parseFloat(style.lineHeight);
    let vertical;
    if (display.includes("flex")) {
      if (alignItems === "center") vertical = "middle";
      else if (alignItems === "flex-start" || alignItems === "start")
        vertical = "top";
      else if (alignItems === "flex-end" || alignItems === "end")
        vertical = "bottom";
    } else if (!isNaN(height) && !isNaN(lineHeight)) {
      if (Math.abs(height - lineHeight) < 0.5) vertical = "middle";
      else if (lineHeight < height / 2) vertical = "top";
      else vertical = "bottom";
    }

    let horizontal;
    if (display.includes("flex")) {
      if (justifyContent === "center") horizontal = "center";
      else if (justifyContent === "flex-start" || justifyContent === "start")
        horizontal = "left";
      else if (justifyContent === "flex-end" || justifyContent === "end")
        horizontal = "right";
    } else {
      if (textAlign === "center") horizontal = "center";
      else if (textAlign === "right" || textAlign === "end")
        horizontal = "right";
      else horizontal = "left"; // 默认 left
    }

    return {
      vertical,
      horizontal,
      isVerticallyCentered: vertical === "middle",
      isHorizontallyCentered: horizontal === "center",
    };
  }

canvas画布对齐有一些不一样,左对齐center,并不是按照size大小居中对齐,而是按照x轴的点进行中心对齐,y轴也是一样,所以在渲染文字时,需要特殊处理。

ts 复制代码
// TextRender.ts
// ...
   // 计算 y 偏移:当 textBaseline 为 middle 时,需要将文字向下偏移半个容器高度
  // 因为 translate 已经移动到了元素的左上角,而 middle 会让文字中心对齐到 y=0
  let offsetY = 0;
  if (textBaseline === "middle" && size) {
    offsetY = size.height / 2;
  }

  // 计算 x 偏移:当 textAlign 为 center 或 right 时,需要调整 x 轴位置
  // 因为 translate 已经移动到了元素的左上角
  let offsetX = 0;
  if (size) {
    if (textAlign === "center") {
      offsetX = size.width / 2;
    } else if (textAlign === "right" || textAlign === "end") {
      offsetX = size.width;
    }
  }
  ```
#### 渲染到画布
```ts
//...
  const dsl = {
      position,
      size,
      font: type === "text" ? this.getFontByStyle(style) : {},
      color,
      selected: { value: false, hovered: false },
      radius,
      img: src || path ? { src, path } : null,
      id: this.id.toString(),
      rotation: { value: 0 },
      zIndex: 30,
      lineWidth,
      eventQueue: [],
      type,
    };

    this.dsls.push(dsl as unknown as DSL);
    if (style.children && style.children.length > 0) {
      this.transformToDSL(style.children);
    }

最后将得到的dsl一次性渲染到画布中。

ts 复制代码
const handlerApplyCode = (data: any[]) => {
  engineRef.current?.core.initComponents(data);
  engineRef.current?.update();
};

渲染引擎

目前采用的canvas api进行绘制,有考虑大数据量的时候,会导致页面卡顿,有打算将渲染引擎给替换成WebGL进行绘制,当然只需要更新Render即可,抽象统一的api进行替换。 WebGL方面目前打算采用PixiJs框架来绘制,总体需要的apicanvas相似,替换成本比较低。当然具体方案还没确定,也许会把整体页面布局搞完再做也说不准,比较目前只有渲染和拖拽移动等功能,由于ECS架构的特性,想来加一些页面功能还是比较快的。

相关推荐
岁月宁静3 小时前
Vue 3.5 + WangEditor 打造智能笔记编辑器:语音识别功能深度实现
前端·javascript·vue.js
非凡ghost3 小时前
BiliLive-tools(B站录播一站式工具) 中文绿色版
前端·javascript·后端
yi碗汤园3 小时前
【一文了解】八大排序-冒泡排序、选择排序
开发语言·前端·算法·unity·c#·1024程序员节
非凡ghost3 小时前
bkViewer小巧精悍数码照片浏览器 中文绿色版
前端·javascript·后端
间彧3 小时前
Java并发编程:乐观锁、悲观锁、公平锁、非公平锁
后端
间彧3 小时前
Java并发编程锁机制解析:乐观锁、悲观锁、公平锁、非公平锁、可重入锁、独占锁、共享锁
后端
三小河3 小时前
JS 自定义事件:从 CustomEvent 到 dispatchEvent
前端
西洼工作室3 小时前
前端监控:错误捕获与行为日志全解析
前端·javascript