第四章(中):Delta 到 HTML 转换的核心方法解析

147 阅读10分钟

4.1 引言

本章将深入解析 vsc_quill_delta_to_html 包中的 QuillDeltaToHtmlConverter 类源码,结合测试数据对应的 Delta 操作,逐行分析核心方法 convert 的实现逻辑,展示数据流向和输出结构,


4.2 QuillDeltaToHtmlConverter 类的源码解析

QuillDeltaToHtmlConverter 类是 Delta 到 HTML 转换的核心入口,负责将 Delta 操作序列转换为 HTML 字符串。以下是源码及逐行中文注释,结合测试数据展示执行过程和数据流向。

4.2.1 核心方法 convert 的源码解析

convert 方法是 Delta 到 HTML 转换的入口,负责将 Delta 操作序列转换为 HTML 字符串。以下是源码及逐行注释:

/// 将 Delta 操作转换为 HTML 字符串。
String convert() {
  final groups = getGroupedOps(); // 获取分组后的操作列表。
  return groups.map((group) {
    if (group is ListGroup) { // 如果是列表组,渲染为 HTML 列表。
      return _renderWithCallbacks(GroupType.list, group, () => _renderList(group));
    }
    if (group is TableGroup) { // 如果是表格组,渲染为 HTML 表格。
      return _renderWithCallbacks(GroupType.table, group, () => _renderTable(group));
    }
    if (group is BlockGroup) { // 如果是块组(如标题、段落),渲染为块级 HTML。
      return _renderWithCallbacks(GroupType.block, group, () => renderBlock(group.op, group.ops));
    }
    if (group is BlotBlock) { // 如果是嵌入块,调用自定义渲染。
      return _renderCustom(group.op, null);
    }
    if (group is VideoItem) { // 如果是视频项,渲染为视频 HTML。
      return _renderWithCallbacks(GroupType.video, group, () {
        var converter = OpToHtmlConverter(group.op, _converterOptions);
        return converter.getHtml();
      });
    }
    // 内联组,渲染为内联 HTML。
    return _renderWithCallbacks(
        GroupType.inlineGroup, group, () => renderInlines((group as InlineGroup).ops, true));
  }).join(''); // 将所有组的 HTML 拼接为最终字符串。
}

执行过程与数据流向

  • 输入:测试数据的 Delta 操作:

    [
      {"insert": "作"},
      {"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}},
      {"insert": "手"},
      {"insert": "\n", "attributes": {"header": 1}},
      {"insert": "大"},
      {"insert": "前端", "attributes": {"background": "var(--warning1-25)"}},
      {"insert": "开发"},
      {"insert": "\n", "attributes": {"header": 2}},
      {"insert": "王金山"},
      {"insert": "\n", "attributes": {"header": 3}},
      {"insert": "欢迎各位大佬"},
      {"insert": "\n"}
    ]
    
  • 步骤 1:调用 getGroupedOps

    • 作用:将原始 Delta 操作分组为不同类型的组(如 ListGroupTableGroupBlockGroupInlineGroup)。

    • 源码

      List<TDataGroup> getGroupedOps() {
        var deltaOps = InsertOpsConverter.convert(_rawDeltaOps, _options.sanitizerOptions); // 将原始 Delta 操作转换为 DeltaInsertOp 对象。
        var pairedOps = Grouper.pairOpsWithTheirBlock(deltaOps); // 将操作与其块上下文配对。
        var groupedSameStyleBlocks = Grouper.groupConsecutiveSameStyleBlocks(
          pairedOps,
          blockquotes: _options.multiLineBlockquote ?? false,
          header: _options.multiLineHeader ?? false,
          codeBlocks: _options.multiLineCodeblock ?? false,
          customBlocks: _options.multiLineCustomBlock ?? false,
        ); // 将连续的相同样式块分组。
        var groupedOps = Grouper.reduceConsecutiveSameStyleBlocksToOne(groupedSameStyleBlocks); // 合并连续的相同样式块。
        groupedOps = TableGrouper().group(groupedOps); // 处理表格分组。
        return ListNester().nest(groupedOps); // 处理列表嵌套。
      }
      
    • 执行过程

      1. 转换操作InsertOpsConverter.convert_rawDeltaOps 转换为 DeltaInsertOp 对象,仅保留 insert 操作。

        • 输入:[{"insert": "作"}, ...]
        • 输出:List<DeltaInsertOp>,每个操作包含 insert 值和 attributes
      2. 配对块Grouper.pairOpsWithTheirBlock 将操作与其块上下文配对。

        • 例如,[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}, {"insert": "\n", "attributes": {"header": 1}}] 配对为一个块,"\n" 是块终止符。
        • 输出:List 包含块配对,如 [Block(op4, [op1, op2, op3])].
      3. 分组相同样式块Grouper.groupConsecutiveSameStyleBlocks 将连续的相同样式块分组。

        • 测试数据中,每个块(header 1、header 2、header 3、paragraph)样式不同,无需合并。
        • 输出:List 包含 [BlockGroup(header 1), BlockGroup(header 2), BlockGroup(header 3), BlockGroup(paragraph)]
      4. 合并块Grouper.reduceConsecutiveSameStyleBlocksToOne 合并连续块(测试数据无连续块)。

      5. 表格分组TableGrouper().group 处理表格操作(测试数据无表格)。

      6. 列表嵌套ListNester().nest 处理列表嵌套(测试数据无列表)。

    • 输出groups 包含四个 BlockGroup

      • BlockGroup(header 1):[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}, {"insert": "\n", "attributes": {"header": 1}}]
      • BlockGroup(header 2):[{"insert": "大"}, {"insert": "前端", ...}, {"insert": "开发"}, {"insert": "\n", "attributes": {"header": 2}}]
      • BlockGroup(header 3):[{"insert": "王金山"}, {"insert": "\n", "attributes": {"header": 3}}]
      • BlockGroup(paragraph):[{"insert": "欢迎各位大佬"}, {"insert": "\n"}]
  • 步骤 2:遍历组并渲染

    • 逻辑:对每个组调用 _renderWithCallbacks,根据组类型选择渲染方法。

    • 测试数据应用

      • BlockGroup(header 1)

        • 调用 _renderWithCallbacks(GroupType.block, group, () => renderBlock(group.op, group.ops))
        • group.op{"insert": "\n", "attributes": {"header": 1}}
        • group.ops[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}].
      • BlockGroup(header 2)

        • 类似处理,group.op{"insert": "\n", "attributes": {"header": 2}}
      • BlockGroup(header 3)

        • group.op{"insert": "\n", "attributes": {"header": 3}}
      • BlockGroup(paragraph)

        • group.op{"insert": "\n"}
  • 步骤 3:渲染块

    • 源码

      String renderBlock(DeltaInsertOp bop, List<DeltaInsertOp> ops) {
        final converter = OpToHtmlConverter(bop, _converterOptions); // 创建操作转换器。
        final htmlParts = converter.getHtmlParts(); // 获取 HTML 标签部分。
        if (bop.isCodeBlock()) {
          return htmlParts.openingTag +
              encodeHtml(ops
                  .map((iop) => iop.isCustomEmbed() ? _renderCustom(iop, bop) : iop.insert.value)
                  .join('')) +
              htmlParts.closingTag; // 处理代码块。
        }
        final inlines = ops.map((op) => _renderInline(op, bop)).join(''); // 渲染内联操作。
        return htmlParts.openingTag + (inlines.isEmpty ? brTag : inlines) + htmlParts.closingTag; // 拼接块 HTML。
      }
      
    • 执行过程

      • Header 1

        • bop{"insert": "\n", "attributes": {"header": 1}}

        • converter.getHtmlParts() 返回 {openingTag: "<h1>", closingTag: "</h1>"}

        • ops[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}].

        • 调用 _renderInline

          • {"insert": "作"}"作"
          • {"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}}<span class="foreshadows" data-uuid="">家助</span>(假设 renderCustomWithCallback 定义)。
          • {"insert": "手"}"手"
        • 输出:<h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1>

      • Paragraph

        • bop{"insert": "\n"}
        • converter.getHtmlParts() 返回 {openingTag: "<p>", closingTag: "</p>"}
        • ops[{"insert": "欢迎各位大佬"}].
        • 输出:<p>欢迎各位大佬</p>
  • 步骤 4:拼接 HTML

    • 所有组的 HTML 拼接为:

      <h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1>
      <h2>大<span style="background-color: var(--warning1-25);">前端</span>开发</h2>
      <h3>王金山</h3>
      <p>欢迎各位大佬</p>
      

数据流向

  • 输入_rawDeltaOpsgetGroupedOpsList<TDataGroup>
  • 处理:遍历组 → 调用渲染方法 → 生成 HTML 片段。
  • 输出:拼接后的 HTML 字符串。

4.2.2 其他关键方法解析

_renderWithCallbacks

  • 作用:包装渲染函数,允许在渲染前后调用回调。

  • 源码

    _renderWithCallbacks(
      GroupType groupType,
      TDataGroup group,
      String Function() myRenderFn,
    ) {
      var html = _beforeRenderCallback?.call(groupType, group) ?? ''; // 调用渲染前回调。
      if (html.isEmpty) {
        html = myRenderFn(); // 执行渲染函数。
      }
      html = _afterRenderCallback?.call(groupType, html) ?? html; // 调用渲染后回调。
      return html; // 返回最终 HTML。
    }
    
  • 测试数据应用:测试数据无自定义回调,myRenderFn 直接执行(如 renderBlock)。

renderInlines

  • 作用:渲染内联操作。

  • 源码

    String renderInlines(List<DeltaInsertOp> ops, [bool isInlineGroup = true]) {
      final opsLen = ops.length - 1;
      final html = ops.mapIndexed((i, op) {
        if (i > 0 && i == opsLen && op.isJustNewline()) {
          return ''; // 忽略最后一个换行符。
        }
        return _renderInline(op, null); // 渲染内联操作。
      }).join('');
      if (!isInlineGroup) {
        return html; // 非内联组直接返回。
      }
      final startParaTag = makeStartTag(_converterOptions.paragraphTag); // 获取段落开始标签。
      final endParaTag = makeEndTag(_converterOptions.paragraphTag); // 获取段落结束标签。
      if (html == brTag || _options.multiLineParagraph == true) {
        return startParaTag + html + endParaTag; // 多行段落处理。
      }
      return startParaTag +
          html.split(brTag).map((v) => v.isEmpty ? brTag : v).join(endParaTag + startParaTag) +
          endParaTag; // 处理多段落。
    }
    
  • 测试数据应用

    • 对于 header 1 的内联:作<span class="foreshadows" data-uuid="">家助</span>手
    • 对于 paragraph:欢迎各位大佬

_renderInline

  • 作用:渲染单个内联操作。

  • 源码

    String _renderInline(DeltaInsertOp op, DeltaInsertOp? contextOp) {
      if (op.isCustomEmbed() || YwQuillFormatUtil.isYwCustomSpan(op)) {
        return _renderCustom(op, contextOp); // 处理自定义嵌入或自定义 span。
      }
      final converter = OpToHtmlConverter(op, _converterOptions); // 创建操作转换器。
      return converter.getHtml().replaceAll('\n', brTag); // 将换行符替换为 <br>。
    }
    
  • 测试数据应用

    • {"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}}<span class="foreshadows" data-uuid="">家助</span>

4.2.3 异步解析的补充

当前源码是同步操作,但在实际应用中可能涉及异步场景:

  • 外部资源加载:如 <img><video>src 属性可能需要异步获取。

  • 大型 Delta 处理:可通过 Future 包装 convert 方法:

    Future<String> convertAsync() async {
      return await Future(() => convert());
    }
    

4.2.4 数据流向与输出结构

  • 输入:Delta 操作列表。

  • 处理

    • 分组:getGroupedOpsList<TDataGroup>
    • 渲染:遍历组 → 调用渲染方法 → 生成 HTML。
  • 输出:HTML 字符串。

示例输出

  • 输入 Delta:如上。

  • 输出 HTML:

    <h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1>
    <h2>大<span style="background-color: var(--warning1-25);">前端</span>开发</h2>
    <h3>王金山</h3>
    <p>欢迎各位大佬</p>
    

4.2.5 优化与扩展建议

  • 性能优化:缓存分组结果,减少重复计算。
  • 样式扩展:增强 OpToHtmlConverter 支持更多 CSS 属性。
  • 异步优化:支持异步渲染外部资源。

4.3 总结

本章通过源码解析,详细讲解了 QuillDeltaToHtmlConverter 类及其 convert 方法的 Delta 到 HTML 转换逻辑,结合测试数据展示了数据流向和输出结构。欢迎高级开发者交流优化方案!


更加详细的流程

我将基于提供的源码一步步解析 Delta 到 HTML 的转换过程,整个过程分为 6 个关键步骤:

转换流程概览

graph TD
A[输入原始 Delta 操作] --> B[操作反规范化和转换]
B --> C[操作分组]
C --> D[块级结构构建]
D --> E[渲染处理]
E --> F[HTML 输出]

步骤 1: 输入处理和操作转换 (InsertOpsConverter.convert)

文件: insert_ops_converter.dart
目的: 将原始 Delta 操作转换为内部操作对象

image.png

static List<DeltaInsertOp> convert(
  List<Map<String, dynamic>>? deltaOps,
  OpAttributeSanitizerOptions options,
) {
  // 1. 反规范化操作
  final denormalizedOps = deltaOps.map(InsertOpDenormalizer.denormalize).flattened.toList();
  
  // 2. 转换操作
  for (final op in denormalizedOps) {
    final rawInsertValue = op['insert'];
    
    // 3. 转换插入值
    final insertVal = convertInsertVal(rawInsertValue, options);
    
    // 4. 清理属性
    final attributes = OpAttributeSanitizer.sanitize(
        OpAttributes()..attrs.addAll(rawAttributes), options);
    
    // 5. 创建操作对象
    results.add(DeltaInsertOp(insertVal, attributes));
  }
}

关键处理:

  1. 反规范化: 将复杂操作拆分为基本操作单元
  2. 值转换:
    • 文本: InsertDataQuill(DataType.text, value)
    • 图片: InsertDataQuill(DataType.image, sanitizedUrl)
    • 视频: InsertDataQuill(DataType.video, sanitizedUrl)
    • 公式: InsertDataQuill(DataType.formula, value)
    • 自定义: InsertDataCustom(type, value)
  3. 属性清理: 确保属性值安全可用

步骤 2: 操作分组 (Grouper.pairOpsWithTheirBlock)

文件: grouper.dart
目的: 将操作分组为逻辑块

static List<TDataGroup> pairOpsWithTheirBlock(List<DeltaInsertOp> ops) {
        // 创建结果列表
        final result = <TDataGroup>[];
        
        // 从后向前遍历所有操作
        var lastInd = ops.length - 1;
        for (var i = lastInd; i >= 0; i--) {
            final op = ops[i];
            
            // 根据当前操作的类型,进行不同的处理
            if (op.isVideo()) {
                // 视频是独立的块
                result.add(VideoItem(op));
            } else if (op.isCustomEmbedBlock()) {
                // 自定义嵌入块也是独立的
                result.add(BlotBlock(op));
            } else if (op.isContainerBlock()) {
                // 如果是容器块(如标题、引用、列表等)
                // 查找前面所有能属于这个块的内容操作
                final opsSlice = sliceFromReverseWhile(ops, i - 1, canBeInBlock);
                // 创建一个块组,op是块本身,opsSlice.elements是块内的内容
                result.add(BlockGroup(op, opsSlice.elements));
                // 调整索引,跳过已处理的操作
                i = opsSlice.sliceStartsAt > -1 ? opsSlice.sliceStartsAt : i;
            } else {
                // 处理内联内容
                final opsSlice = sliceFromReverseWhile(ops, i - 1, isInlineData);
                // 创建内联组,包含当前操作和前面的所有内联操作
                result.add(InlineGroup(opsSlice.elements..add(op)));
                // 调整索引,跳过已处理的操作
                i = opsSlice.sliceStartsAt > -1 ? opsSlice.sliceStartsAt : i;
            }
        }
        // 反转结果,因为我们是从后向前处理的
        return result.reversed.toList();
    }

分组类型:

  1. VideoItem: 视频内容
  2. BlotBlock: 自定义块
  3. BlockGroup: 块级元素(段落、标题等)
  4. InlineGroup: 内联元素(文本、样式等)

步骤 3: 块级结构构建

image.png

3.1 相同样式块分组 (Grouper.groupConsecutiveSameStyleBlocks)

static List<dynamic> groupConsecutiveSameStyleBlocks(
  List<TDataGroup> groups, {
  bool header = true,
  bool codeBlocks = true,
  bool blockquotes = true,
  bool customBlocks = true,
}) {
  return groupConsecutiveElementsWhile(groups, (g, gPrev) {
    return ((codeBlocks && areBothCodeblocksWithSameLang(g, gPrev)) ||
            (blockquotes && areBothBlockquotesWithSameAdi(g, gPrev)) ||
            (header && areBothSameHeadersWithSameAdi(g, gPrev)) ||
            (customBlocks && areBothCustomBlockWithSameAttr(g, gPrev)));
  });
}

合并规则:

  • 相同语言的代码块
  • 相同属性的引用块
  • 相同级别的标题
  • 相同属性的自定义块

3.2 表格分组 (TableGrouper.group)

List<TDataGroup> group(List<TDataGroup> groups) {
  var tableBlocked = _convertTableBlocksToTableGroups(groups);
  return tableBlocked;
}

List<TDataGroup> _convertTableBlocksToTableGroups(List<TDataGroup> items) {
  var grouped = groupConsecutiveElementsWhile(items, (g, gPrev) {
    return g is BlockGroup && gPrev is BlockGroup && 
           g.op.isTable() && gPrev.op.isTable();
  });
}

表格结构:

  • TableGroup: 整个表格
  • TableRow: 表格行
  • TableCell: 表格单元格

3.3 列表嵌套 (ListNester.nest)

List<TDataGroup> nest(List<TDataGroup> groups) {
  // 处理列表嵌套逻辑
  for (final block in blocks) {
    if (block.isList()) {
      // 处理列表层级嵌套
      while (stack.isNotEmpty && stack.last.listLevel >= block.listLevel) {
        final last = stack.removeLast();
        result.add(last..closeTags());
      }
      stack.add(block);
    }
  }
}

嵌套逻辑:

  • 维护堆栈跟踪列表层级
  • 根据缩进级别嵌套列表
  • 自动闭合父级列表标签

步骤 4: 渲染处理 (QuillDeltaToHtmlConverter.convert)

文件: vsc_quill_delta_to_html.dart
目的: 将分组后的操作渲染为 HTML

String convert() {
  final groups = getGroupedOps();
  return groups.map((group) {
    if (group is ListGroup) {
      return _renderList(group); // 列表渲染
    }
    if (group is TableGroup) {
      return _renderTable(group); // 表格渲染
    }
    if (group is BlockGroup) {
      return renderBlock(group.op, group.ops); // 块级渲染
    }
    if (group is BlotBlock) {
      return _renderCustom(group.op, null); // 自定义块渲染
    }
    if (group is VideoItem) {
      return _renderVideo(group); // 视频渲染
    }
    return renderInlines((group as InlineGroup).ops, true); // 内联渲染
  }).join('');
}

步骤 5: 具体渲染实现

5.1 列表渲染 (_renderList)

String _renderList(ListGroup list) {
  return makeStartTag(getListTag(firstItem.item.op)) +
         list.items.map((li) => _renderListItem(li)).join('') +
         makeEndTag(getListTag(firstItem.item.op));
}

String _renderListItem(ListItem li) {
  final converter = OpToHtmlConverter(li.item.op, _converterOptions);
  final parts = converter.getHtmlParts();
  
  return parts.openingTag +
         renderInlines(li.item.ops, false) + // 渲染列表项内容
         (li.innerList != null ? _renderList(li.innerList!) : '') + // 嵌套列表
         parts.closingTag;
}

5.2 表格渲染 (_renderTable)

String _renderTable(TableGroup table) {
  return makeStartTag('table') +
         makeStartTag('tbody') +
         table.rows.map((row) => _renderTableRow(row)).join('') +
         makeEndTag('tbody') +
         makeEndTag('table');
}

String _renderTableRow(TableRow row) {
  return makeStartTag('tr') +
         row.cells.map((cell) => _renderTableCell(cell)).join('') +
         makeEndTag('tr');
}

String _renderTableCell(TableCell cell) {
  final converter = OpToHtmlConverter(cell.item.op, _converterOptions);
  final parts = converter.getHtmlParts();
  
  return makeStartTag('td') +
         parts.openingTag +
         renderInlines(cell.item.ops, false) + // 单元格内容
         parts.closingTag +
         makeEndTag('td');
}

5.3 块级渲染 (renderBlock)

@visibleForTesting
String renderBlock(DeltaInsertOp bop, List<DeltaInsertOp> ops) {
  // 1. 创建块级转换器 - 核心转换逻辑
  final converter = OpToHtmlConverter(bop, _converterOptions);
  
  // 2. 获取块级标签的开闭部分
  final htmlParts = converter.getHtmlParts();

  // 3. 特殊处理:代码块
  if (bop.isCodeBlock()) {
    return htmlParts.openingTag +  // <pre> 开始标签
        encodeHtml(ops.map((iop) =>  // HTML 实体转义
            iop.isCustomEmbed() 
                ? _renderCustom(iop, bop)  // 自定义嵌入处理
                : iop.insert.value)         // 普通文本内容
            .join('')) +                    // 拼接所有内容
        htmlParts.closingTag;               // </pre> 结束标签
  }

  // 4. 处理非代码块的块级元素
  final inlines = ops.map((op) => _renderInline(op, bop)).join('');
  
  // 5. 组合块级标签和内联内容
  return htmlParts.openingTag +     // 块级开始标签(如 <h1>)
      (inlines.isEmpty 
          ? brTag                   // 空内容时添加换行符防止折叠
          : inlines) +              // 内联内容
      htmlParts.closingTag;         // 块级结束标签(如 </h1>)
}

5.4 内联渲染 (renderInlines)

String renderInlines(List<DeltaInsertOp> ops, [bool isInlineGroup = true]) {
  final html = ops.mapIndexed((i, op) {
    if (i > 0 && i == opsLen && op.isJustNewline()) return '';
    return _renderInline(op, null);
  }).join('');
  
  if (!isInlineGroup) return html;

  final startParaTag = makeStartTag(_converterOptions.paragraphTag);
  final endParaTag = makeEndTag(_converterOptions.paragraphTag);
  
  return startParaTag + 
         html.split(brTag).map((v) => v.isEmpty ? brTag : v).join(endParaTag + startParaTag) +
         endParaTag;
}

步骤 6: HTML 最终输出

处理流程:

  1. 遍历所有分组
  2. 根据分组类型调用相应渲染方法
  3. 拼接所有渲染结果
  4. 应用全局 HTML 优化
String convert() {
  final groups = getGroupedOps();
  return groups.map((group) {
    // 根据不同类型渲染
    return ...;
  }).join(''); // 拼接最终HTML
}

高级特性实现

自定义渲染回调

// 设置自定义渲染回调
converter.renderCustomWith = (op, contextOp) {
  if (op.insert.value is Map && op.insert.value['tweet'] != null) {
    return '<div class="tweet">${op.insert.value['id']}</div>';
  }
  return '';
};

// 渲染过程中调用
String _renderCustom(DeltaInsertOp op, DeltaInsertOp? contextOp) {
  return _renderCustomWithCallback?.call(op, contextOp) ?? '';
}

样式转换

// 在 OpToHtmlConverter 中
String _getCssStyles() {
  final styles = <String>[];
  
  if (op.isBold()) styles.add('font-weight:bold');
  if (op.isItalic()) styles.add('font-style:italic');
  if (op.attributes.color != null) styles.add('color:${op.attributes.color}');
  
  // 自定义样式转换
  if (_converterOptions.customCssStyles != null) {
    final customStyles = _converterOptions.customCssStyles!(op);
    if (customStyles != null) styles.addAll(customStyles);
  }
  
  return styles.join(';');
}

转换过程关键点

  1. 逆序处理: 操作从后向前处理,便于构建嵌套结构
  2. 分组策略: 将相关操作分组,保持结构完整性
  3. 类型分发: 根据操作类型分发到不同渲染器
  4. 递归渲染: 列表和表格使用递归渲染处理嵌套
  5. 上下文感知: 渲染时考虑父级元素的上下文
  6. 安全处理: 所有属性和链接都经过清理

这个转换过程通过精心设计的步骤将线性的 Delta 操作序列转换为结构化的 HTML,完整保留了原始内容的语义和样式信息。

Key Citations