You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何将Figma文本段数组转换为DOM树对象?

问题:将Figma文本段转换为HTML结构

在Figma中编辑一个文本块后,Figma Plugin API返回如下文本段(segments)数据:

const segments = [
  { "characters": "Lorem ", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": "Ipsum", "fontWeight": 700, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": " is \nsimply dummy text of \n", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": "the printing and \n", "fontWeight": 400, "listOptions": { "type": "UNORDERED" }, "indentation": 1, "hyperlink": null },
  { "characters": "typesetting \n", "fontWeight": 400, "listOptions": { "type": "UNORDERED" }, "indentation": 2, "hyperlink": null },
  { "characters": "industry. \n", "fontWeight": 400, "listOptions": { "type": "UNORDERED" }, "indentation": 1, "hyperlink": null },
  { "characters": "Lorem Ipsum has been the ", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": "industry's standard", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": { "type": "URL", "value": "http://example.com" } },
  { "characters": " dummy text ever since the 1500s, \n", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": "when an unknown \n", "fontWeight": 400, "listOptions": { "type": "ORDERED" }, "indentation": 1, "hyperlink": null },
  { "characters": "printer took \na galley of \ntype and \n", "fontWeight": 400, "listOptions": { "type": "UNORDERED" }, "indentation": 2, "hyperlink": null },
  { "characters": "scrambled it\n", "fontWeight": 400, "listOptions": { "type": "ORDERED" }, "indentation": 1, "hyperlink": null },
  { "characters": "\nto make a type\n\n", "fontWeight": 400, "listOptions": { "type": "NONE" }, "indentation": 0, "hyperlink": null },
  { "characters": "specimen book.\n", "fontWeight": 400, "listOptions": { "type": "UNORDERED" }, "indentation": 1, "hyperlink": null },
  { "characters": "It has survived\n", "fontWeight": 400, "listOptions": { "type": "ORDERED" }, "indentation": 3, "hyperlink": null },
  { "characters": "not only\nfive centuries,", "fontWeight": 400, "listOptions": { "type": "ORDERED" }, "indentation": 2, "hyperlink": null }
]

为简化阅读,将数据精简为:

const segments = [
  { ind: 0, list: null, chars: "Lorem ", bold: false, link: null },
  { ind: 0, list: null, chars: "Ipsum", bold: true, link: null },
  { ind: 0, list: null, chars: " is \nsimply dummy text of \n", bold: false, link: null },
  { ind: 1, list: "UL", chars: "the printing and \n", bold: false, link: null },
  { ind: 2, list: "UL", chars: "typesetting \n", bold: false, link: null },
  { ind: 1, list: "UL", chars: "industry. \n", bold: false, link: null },
  { ind: 0, list: null, chars: "Lorem Ipsum has been the ", bold: false, link: null },
  { ind: 0, list: null, chars: "industry's standard", bold: false, link: "http://example.com" },
  { ind: 0, list: null, chars: " dummy text ever since the 1500s, \n", bold: false, link: null },
  { ind: 1, list: "OL", chars: "when an unknown \n", bold: false, link: null },
  { ind: 2, list: "UL", chars: "printer took \na galley of \ntype and \n", bold: false, link: null },
  { ind: 1, list: "OL", chars: "scrambled it\n", bold: false, link: null },
  { ind: 0, list: null, chars: "\nto make a type\n\n", bold: false, link: null },
  { ind: 1, list: "UL", chars: "specimen book.\n", bold: false, link: null },
  { ind: 3, list: "OL", chars: "It has survived\n", bold: false, link: null },
  { ind: 2, list: "OL", chars: " not only\nfive centuries,", bold: false, link: null }
]

需要将这些文本段转换为如下HTML结构:

<span>Lorem </span>
<strong>Ipsum</strong> 
<span> is <br>simply dummy text of </span>
<ul>
  <li>
    <span>the printing and </span>
  </li>
  <ul>
    <li><span>typesetting </span></li>
  </ul>
  <li><span>industry. </span></li>
</ul>
<span>Lorem Ipsum has been the </span>
<a href="http://example.com">industry's standard</a>
<span> dummy text ever since the 1500s, </span>
<ol>
  <li><span>when an unknown </span></li>
  <ul>
    <li><span>printer took </span></li>
    <li><span>a galley of </span></li>
    <li><span>type and </span></li>
  </ul>
  <li><span>scrambled it</span></li>
</ol>
<span>to make a type</span>
<ul>
  <li>
    <span>specimen book.</span>
  </li>
  <ol>
    <ol>
      <li><span>It has survived</span></li>
    </ol>
    <li><span>not only</span></li>
    <li><span>five centuries,</span></li>
  </ol>
</ul>

以下是尝试编写的代码,但无法生成符合预期的DOM结构:

function getPureSegment(chars: string) {
  if (chars.endsWith("\n")) chars = chars.slice(0, -1)
  return ["<span>", chars.replaceAll(/\n/g, "<br>"), "</span>"]
}

function getOpeningListTag(segment) {
  const type = segment.listOptions.type
  if (type === "ORDERED") return "<ol>"
  if (type === "UNORDERED") return "<ul>"
}

function getClosingListTag(segment) {
  const type = segment.listOptions.type
  if (type === "ORDERED") return "</ol>"
  if (type === "UNORDERED") return "</ul>"
}

function getHtml(segments) {
  let prevSegment = { indentation: 0 }
  return segments.flatMap((segment, idx) => {
    const pure = getPureSegment(segment.characters)
    let line
    const endsBreakLine = segment.characters.endsWith("\n")
    const isLastSegment = idx === segments.length - 1
    if (segment.indentation == 0) {
      if (segment.indentation < prevSegment.indentation) {
        line = [getClosingListTag(prevSegment), ...pure]
      } else {
        line = pure
      }
    } else if (segment.indentation > 0) {
      if (segment.indentation > prevSegment.indentation) {
        line = [getOpeningListTag(segment), "<li>", ...pure, (isLastSegment || segments[idx + 1].indentation < segment.indentation) && "</li>"].filter(Boolean)
      } else if (segment.indentation == prevSegment.indentation) {
        line = [segments[idx - 1].characters.endsWith("\n") && "<li>", ...pure, endsBreakLine && "</li>"].filter(Boolean)
      } else {
        line = [getClosingListTag(segment), "<li>", ...pure, endsBreakLine && "</li>"].filter(Boolean)
      }
      if (isLastSegment) line.push("</ul>".repeat(segment.indentation))
    }
    prevSegment = segment
    return line
  }).join("\n")
}

解决方案

原代码仅通过前后缩进的简单比较处理列表嵌套,未维护列表层级状态栈,也未正确处理列表类型切换、li标签闭合时机及多行文本拆分逻辑。以下是修正后的代码:

// 处理单个文本段的内容,生成对应HTML标签(包含加粗、链接、换行)
function getSegmentContent(segment: { characters: string; fontWeight: number; hyperlink: { value: string } | null }) {
  let content = segment.characters.replace(/\n/g, "<br>");
  // 移除末尾的换行(如果有)
  if (segment.characters.endsWith("\n")) {
    content = content.slice(0, -4); // 去掉最后的<br>
  }

  // 处理超链接
  if (segment.hyperlink) {
    return `<a href="${segment.hyperlink.value}">${content}</a>`;
  }
  // 处理加粗
  if (segment.fontWeight >= 700) {
    return `<strong>${content}</strong>`;
  }
  // 默认用span包裹
  return `<span>${content}</span>`;
}

// 获取列表的开始标签
function getListStartTag(type: string) {
  return type === "ORDERED" ? "<ol>" : "<ul>";
}

// 获取列表的结束标签
function getListEndTag(type: string) {
  return type === "ORDERED" ? "</ol>" : "</ul>";
}

function convertSegmentsToHtml(segments: any[]) {
  const output: string[] = [];
  // 栈结构,保存当前的列表层级信息:{ indent: 缩进级别, type: 列表类型 }
  const listStack: { indent: number; type: string }[] = [];
  let currentIndent = 0;
  let currentLiOpen = false;

  // 遍历所有文本段
  segments.forEach((segment, idx) => {
    const { indentation, listOptions, characters } = segment;
    const listType = listOptions.type;
    const isList = listType !== "NONE";
    const nextSegment = segments[idx + 1];
    const nextIndent = nextSegment ? nextSegment.indentation : 0;
    const endsWithNewLine = characters.endsWith("\n");

    // 1. 处理缩进变化:关闭需要闭合的列表
    while (currentIndent > indentation) {
      const closedList = listStack.pop()!;
      output.push(getListEndTag(closedList.type));
      currentIndent--;
      currentLiOpen = false;
    }

    // 2. 处理非列表文本段
    if (!isList) {
      // 如果之前有未闭合的li,先闭合
      if (currentLiOpen) {
        output.push("</li>");
        currentLiOpen = false;
      }
      output.push(getSegmentContent(segment));
      currentIndent = indentation;
      return;
    }

    // 3. 处理列表文本段
    // 打开新的列表(当缩进增加时)
    while (currentIndent < indentation) {
      output.push(getListStartTag(listType));
      listStack.push({ indent: currentIndent + 1, type: listType });
      currentIndent++;
    }

    // 处理li标签:如果当前没有打开的li,或者需要新建li(换行后)
    if (!currentLiOpen || endsWithNewLine || (nextSegment && nextIndent === indentation && nextSegment.listOptions.type === listType)) {
      if (currentLiOpen) {
        output.push("</li>");
      }
      output.push("<li>");
      currentLiOpen = true;
    }

    // 添加当前段的内容
    output.push(getSegmentContent(segment));

    // 如果当前段末尾是换行,且下一段不是同层级同类型的列表,闭合li
    if (endsWithNewLine && (!nextSegment || nextIndent !== indentation || nextSegment.listOptions.type !== listType)) {
      output.push("</li>");
      currentLiOpen = false;
    }
  });

  // 遍历结束后,闭合所有剩余的列表
  while (listStack.length > 0) {
    const closedList = listStack.pop()!;
    if (currentLiOpen) {
      output.push("</li>");
      currentLiOpen = false;
    }
    output.push(getListEndTag(closedList.type));
  }

  // 格式化输出(可选,用于对齐缩进)
  return formatHtml(output.join(""));
}

// 可选:格式化HTML代码,使其更易读
function formatHtml(html: string): string {
  let formatted = "";
  let indentLevel = 0;
  const indent = "  ";
  let inTag = false;

  for (let i = 0; i < html.length; i++) {
    const char = html[i];
    if (char === "<") {
      inTag = true;
      // 判断是结束标签
      if (html[i + 1] === "/") {
        indentLevel--;
        formatted += "\n" + indent.repeat(indentLevel);
      } else {
        formatted += "\n" + indent.repeat(indentLevel);
      }
    }
    formatted += char;
    if (char === ">") {
      inTag = false;
      // 判断是开始标签且不是自闭合标签
      if (html[i - 1] !== "/" && html[i - 2] !== "/") {
        if (!html.slice(i + 1).match(/^\s*<\//)) {
          indentLevel++;
        }
      }
    }
  }

  return formatted.trim();
}

// 使用示例
const htmlResult = convertSegmentsToHtml(segments);
console.log(htmlResult);

代码说明

  1. 状态栈维护:用listStack跟踪当前嵌套的列表层级和类型,解决多层列表嵌套的闭合问题。
  2. 文本段处理:单独处理加粗、超链接、换行,确保每个文本段生成正确的HTML标签。
  3. 列表与li的逻辑:根据缩进变化、换行符、下一段的状态,精准控制li标签的打开和闭合时机。
  4. 格式化输出:可选的formatHtml函数用于美化输出的HTML结构,便于阅读。

该代码可正确生成符合预期的HTML结构,处理多层嵌套列表、不同列表类型切换、文本样式(加粗、链接)以及换行拆分li的逻辑。

内容的提问来源于stack exchange,提问作者Cihad Paksoy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 09:29:52