Эх сурвалжийг харах

feat: 支持逐Run提取Word文档格式并在前端还原

后端改动:
1. WordStructuredExtractionService: 新增 TextRun 数据结构,逐 Run 提取
   字体格式(fontFamily, fontSize, bold, italic, underline, color,
   strikeThrough, verticalAlign, highlightColor)
2. 自动合并相邻的相同样式 Run 以优化数据量
3. DocumentElement: 新增 runs 字段存储文本片段列表
4. DocumentBlock.TextStyle: 扩展支持更多样式属性
5. DocumentBlock.toHtml/toMarkedHtml: 渲染时应用 TextElement 样式
6. StructuredDocumentDTO: 新增 ParagraphDTO 和 TextRunDTO,
   通过 paragraphs 字段传递格式信息到前端
7. 数据库迁移: 添加 runs JSONB 列

前端改动:
1. Editor.vue: 新增 renderParagraphWithRuns, renderTextRun 等函数
2. 支持渲染字体、字号、颜色、加粗、斜体、下划线(多种类型)、
   删除线、上下标、高亮等格式
3. 优先使用 paragraphs (带 runs) 渲染,降级使用 blocks
何文松 4 долоо хоног өмнө
parent
commit
5c023c0488

+ 70 - 0
backend/document-service/src/main/java/com/lingyue/document/dto/StructuredDocumentDTO.java

@@ -47,6 +47,9 @@ public class StructuredDocumentDTO {
     @Schema(description = "图片列表(从 document_elements 中提取)")
     private List<ImageDTO> images;
     
+    @Schema(description = "段落列表(从 document_elements 中提取,包含格式信息)")
+    private List<ParagraphDTO> paragraphs;
+    
     @Schema(description = "实体统计")
     private EntityStats entityStats;
     
@@ -120,6 +123,73 @@ public class StructuredDocumentDTO {
         private String format;
     }
     
+    /**
+     * 文本片段(Run)DTO,保留字符级样式
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "文本片段")
+    public static class TextRunDTO {
+        
+        @Schema(description = "文本内容")
+        private String text;
+        
+        @Schema(description = "字体名称")
+        private String fontFamily;
+        
+        @Schema(description = "字号(磅)")
+        private Double fontSize;
+        
+        @Schema(description = "加粗")
+        private Boolean bold;
+        
+        @Schema(description = "斜体")
+        private Boolean italic;
+        
+        @Schema(description = "下划线类型")
+        private String underline;
+        
+        @Schema(description = "字体颜色")
+        private String color;
+        
+        @Schema(description = "删除线")
+        private Boolean strikeThrough;
+        
+        @Schema(description = "垂直对齐")
+        private String verticalAlign;
+        
+        @Schema(description = "高亮颜色")
+        private String highlightColor;
+    }
+    
+    /**
+     * 段落元素 DTO(从 document_elements 表获取)
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "段落元素")
+    public static class ParagraphDTO {
+        
+        @Schema(description = "元素在文档中的顺序索引")
+        private Integer index;
+        
+        @Schema(description = "元素类型")
+        private String type;
+        
+        @Schema(description = "文本内容")
+        private String content;
+        
+        @Schema(description = "段落样式")
+        private java.util.Map<String, Object> style;
+        
+        @Schema(description = "文本片段列表(保留格式)")
+        private java.util.List<TextRunDTO> runs;
+    }
+    
     /**
      * 实体统计
      */

+ 93 - 5
backend/document-service/src/main/java/com/lingyue/document/entity/DocumentBlock.java

@@ -112,11 +112,14 @@ public class DocumentBlock extends SimpleModel {
     public static class TextStyle {
         private Boolean bold;
         private Boolean italic;
-        private Boolean underline;
+        private String underline;          // 下划线类型:single/double/wave/dotted/dashed/none
         private Boolean strikethrough;
-        private String textColor;
-        private String backgroundColor;
+        private String textColor;          // 字体颜色(十六进制)
+        private String backgroundColor;    // 背景色/高亮色
         private Boolean inlineCode;
+        private String fontFamily;         // 字体名称
+        private Double fontSize;           // 字号(磅)
+        private String verticalAlign;      // 垂直对齐:baseline/superscript/subscript
     }
     
     /**
@@ -147,7 +150,7 @@ public class DocumentBlock extends SimpleModel {
         StringBuilder sb = new StringBuilder();
         for (TextElement el : elements) {
             if ("text_run".equals(el.getType())) {
-                sb.append(escapeHtml(el.getContent()));
+                sb.append(wrapWithStyle(escapeHtml(el.getContent()), el.getStyle()));
             } else if ("entity".equals(el.getType())) {
                 sb.append(escapeHtml(el.getEntityText()));
             } else if ("link".equals(el.getType())) {
@@ -168,7 +171,7 @@ public class DocumentBlock extends SimpleModel {
         StringBuilder sb = new StringBuilder();
         for (TextElement el : elements) {
             if ("text_run".equals(el.getType())) {
-                sb.append(escapeHtml(el.getContent()));
+                sb.append(wrapWithStyle(escapeHtml(el.getContent()), el.getStyle()));
             } else if ("entity".equals(el.getType())) {
                 String cssClass = getEntityCssClass(el.getEntityType());
                 String safeEntityId = escapeHtml(el.getEntityId());
@@ -185,6 +188,91 @@ public class DocumentBlock extends SimpleModel {
         return wrapWithTag(sb.toString());
     }
     
+    /**
+     * 用样式包裹文本
+     */
+    private String wrapWithStyle(String text, TextStyle style) {
+        if (style == null) {
+            return text;
+        }
+        
+        StringBuilder styleBuilder = new StringBuilder();
+        
+        // 字体
+        if (style.getFontFamily() != null) {
+            styleBuilder.append("font-family:").append(style.getFontFamily()).append(";");
+        }
+        
+        // 字号
+        if (style.getFontSize() != null && style.getFontSize() > 0) {
+            styleBuilder.append("font-size:").append(style.getFontSize()).append("pt;");
+        }
+        
+        // 颜色
+        if (style.getTextColor() != null && !style.getTextColor().isEmpty()) {
+            String color = style.getTextColor();
+            if (!color.startsWith("#")) {
+                color = "#" + color;
+            }
+            styleBuilder.append("color:").append(color).append(";");
+        }
+        
+        // 背景色/高亮
+        if (style.getBackgroundColor() != null && !style.getBackgroundColor().isEmpty()) {
+            styleBuilder.append("background-color:").append(style.getBackgroundColor()).append(";");
+        }
+        
+        // 加粗
+        if (Boolean.TRUE.equals(style.getBold())) {
+            styleBuilder.append("font-weight:bold;");
+        }
+        
+        // 斜体
+        if (Boolean.TRUE.equals(style.getItalic())) {
+            styleBuilder.append("font-style:italic;");
+        }
+        
+        // 下划线
+        if (style.getUnderline() != null && !"none".equalsIgnoreCase(style.getUnderline())) {
+            String underlineStyle = switch (style.getUnderline().toLowerCase()) {
+                case "double" -> "double";
+                case "wave", "wavy" -> "wavy";
+                case "dotted" -> "dotted";
+                case "dashed" -> "dashed";
+                default -> "solid";
+            };
+            styleBuilder.append("text-decoration:underline ").append(underlineStyle).append(";");
+        }
+        
+        // 删除线
+        if (Boolean.TRUE.equals(style.getStrikethrough())) {
+            if (styleBuilder.toString().contains("text-decoration:")) {
+                // 已有下划线,追加删除线
+                String current = styleBuilder.toString();
+                styleBuilder = new StringBuilder(current.replace("text-decoration:", "text-decoration:line-through "));
+            } else {
+                styleBuilder.append("text-decoration:line-through;");
+            }
+        }
+        
+        // 上下标
+        if (style.getVerticalAlign() != null) {
+            String align = style.getVerticalAlign().toLowerCase();
+            if ("superscript".equals(align)) {
+                return "<sup>" + text + "</sup>";
+            } else if ("subscript".equals(align)) {
+                return "<sub>" + text + "</sub>";
+            }
+        }
+        
+        // 如果没有样式,直接返回文本
+        if (styleBuilder.isEmpty()) {
+            return text;
+        }
+        
+        return "<span style=\"" + styleBuilder + "\">" + text + "</span>";
+    }
+    
     private String wrapWithTag(String content) {
         return switch (blockType) {
             case "heading1" -> "<h1>" + content + "</h1>";

+ 9 - 1
backend/document-service/src/main/java/com/lingyue/document/entity/DocumentElement.java

@@ -46,12 +46,20 @@ public class DocumentElement extends SimpleModel {
     private String content;
     
     /**
-     * 样式信息(JSON)
+     * 段落级样式信息(JSON)
      * {alignment, fontSize, fontFamily, bold, italic, underline, color, indentLeft, indentFirstLine, lineSpacing}
      */
     @TableField(value = "style", typeHandler = PostgreSqlJsonbTypeHandler.class)
     private Map<String, Object> style;
     
+    /**
+     * 文本片段列表(JSON)
+     * 保留段落内不同格式的文本片段(Run)
+     * [{text, fontFamily, fontSize, bold, italic, underline, color, strikeThrough, verticalAlign, highlightColor}, ...]
+     */
+    @TableField(value = "runs", typeHandler = PostgreSqlJsonbTypeHandler.class)
+    private List<Map<String, Object>> runs;
+    
     // ========== 图片相关 ==========
     
     /**

+ 6 - 1
backend/document-service/src/main/java/com/lingyue/document/service/DocumentElementService.java

@@ -112,11 +112,16 @@ public class DocumentElementService {
         entity.setElementType((String) element.get("type"));
         entity.setContent((String) element.get("content"));
         
-        // 样式
+        // 段落样式
         @SuppressWarnings("unchecked")
         Map<String, Object> style = (Map<String, Object>) element.get("style");
         entity.setStyle(style);
         
+        // 文本片段(runs)
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> runs = (List<Map<String, Object>>) element.get("runs");
+        entity.setRuns(runs);
+        
         // 图片相关
         entity.setImageUrl((String) element.get("imageUrl"));
         entity.setImagePath((String) element.get("imagePath"));

+ 49 - 0
backend/document-service/src/main/java/com/lingyue/document/service/StructuredDocumentService.java

@@ -61,6 +61,9 @@ public class StructuredDocumentService {
         // 5. 获取图片列表
         List<ImageDTO> images = buildImageList(documentId);
         
+        // 6. 获取段落列表(包含格式信息)
+        List<StructuredDocumentDTO.ParagraphDTO> paragraphs = buildParagraphList(documentId);
+        
         return StructuredDocumentDTO.builder()
                 .documentId(documentId)
                 .revision(1) // TODO: 实现版本控制
@@ -68,6 +71,7 @@ public class StructuredDocumentService {
                 .status(document.getStatus())
                 .blocks(blockDTOs)
                 .images(images)
+                .paragraphs(paragraphs)
                 .entityStats(stats)
                 .updatedAt(document.getUpdateTime())
                 .build();
@@ -91,6 +95,51 @@ public class StructuredDocumentService {
                 .collect(Collectors.toList());
     }
     
+    /**
+     * 构建段落列表(从 document_elements 表获取,包含格式信息)
+     */
+    private List<StructuredDocumentDTO.ParagraphDTO> buildParagraphList(String documentId) {
+        List<DocumentElement> elements = documentElementService.getElementsByDocumentId(documentId);
+        
+        return elements.stream()
+                .filter(el -> el.getElementType() != null && !el.getElementType().equals("image") && !el.getElementType().equals("table"))
+                .map(this::convertToParagraphDTO)
+                .collect(Collectors.toList());
+    }
+    
+    /**
+     * 将 DocumentElement 转换为 ParagraphDTO
+     */
+    @SuppressWarnings("unchecked")
+    private StructuredDocumentDTO.ParagraphDTO convertToParagraphDTO(DocumentElement el) {
+        List<StructuredDocumentDTO.TextRunDTO> runDTOs = null;
+        
+        if (el.getRuns() != null && !el.getRuns().isEmpty()) {
+            runDTOs = el.getRuns().stream()
+                    .map(run -> StructuredDocumentDTO.TextRunDTO.builder()
+                            .text((String) run.get("text"))
+                            .fontFamily((String) run.get("fontFamily"))
+                            .fontSize(run.get("fontSize") instanceof Number ? ((Number) run.get("fontSize")).doubleValue() : null)
+                            .bold((Boolean) run.get("bold"))
+                            .italic((Boolean) run.get("italic"))
+                            .underline((String) run.get("underline"))
+                            .color((String) run.get("color"))
+                            .strikeThrough((Boolean) run.get("strikeThrough"))
+                            .verticalAlign((String) run.get("verticalAlign"))
+                            .highlightColor((String) run.get("highlightColor"))
+                            .build())
+                    .collect(Collectors.toList());
+        }
+        
+        return StructuredDocumentDTO.ParagraphDTO.builder()
+                .index(el.getElementIndex())
+                .type(el.getElementType())
+                .content(el.getContent())
+                .style(el.getStyle())
+                .runs(runDTOs)
+                .build();
+    }
+    
     /**
      * 构建块 DTO
      */

+ 163 - 2
backend/parse-service/src/main/java/com/lingyue/parse/service/WordStructuredExtractionService.java

@@ -103,7 +103,7 @@ public class WordStructuredExtractionService {
                         }
                     }
                     
-                    // 提取段落文本
+                    // 提取段落文本及格式
                     String text = paragraph.getText();
                     if (text != null && !text.trim().isEmpty()) {
                         ContentElement textElement = new ContentElement();
@@ -111,6 +111,8 @@ public class WordStructuredExtractionService {
                         textElement.setType(detectParagraphType(paragraph, text));
                         textElement.setContent(text.trim());
                         textElement.setStyle(extractParagraphStyle(paragraph));
+                        // 逐 Run 提取格式
+                        textElement.setRuns(extractTextRuns(paragraph.getRuns()));
                         
                         elements.add(textElement);
                         fullText.append(text).append("\n");
@@ -219,6 +221,8 @@ public class WordStructuredExtractionService {
                 cellData.setRow(i);
                 cellData.setCol(j);
                 cellData.setText(cell.getText());
+                // 提取单元格内的文本格式
+                cellData.setRuns(extractCellRuns(cell));
                 
                 // 提取单元格样式
                 try {
@@ -360,6 +364,144 @@ public class WordStructuredExtractionService {
         return style.isEmpty() ? null : style;
     }
     
+    /**
+     * 提取段落中所有 Run 的文本和样式
+     */
+    private List<TextRun> extractTextRuns(List<XWPFRun> xwpfRuns) {
+        if (xwpfRuns == null || xwpfRuns.isEmpty()) {
+            return null;
+        }
+        
+        List<TextRun> runs = new ArrayList<>();
+        for (XWPFRun xwpfRun : xwpfRuns) {
+            String text = xwpfRun.text();
+            if (text == null || text.isEmpty()) {
+                continue;
+            }
+            
+            TextRun run = new TextRun();
+            run.setText(text);
+            
+            try {
+                // 字体
+                if (xwpfRun.getFontFamily() != null) {
+                    run.setFontFamily(xwpfRun.getFontFamily());
+                }
+                
+                // 字号
+                Double fontSize = xwpfRun.getFontSizeAsDouble();
+                if (fontSize != null && fontSize > 0) {
+                    run.setFontSize(fontSize);
+                }
+                
+                // 加粗
+                if (xwpfRun.isBold()) {
+                    run.setBold(true);
+                }
+                
+                // 斜体
+                if (xwpfRun.isItalic()) {
+                    run.setItalic(true);
+                }
+                
+                // 下划线
+                UnderlinePatterns underline = xwpfRun.getUnderline();
+                if (underline != null && underline != UnderlinePatterns.NONE) {
+                    run.setUnderline(underline.name().toLowerCase());
+                }
+                
+                // 颜色
+                String color = xwpfRun.getColor();
+                if (color != null && !color.isEmpty()) {
+                    run.setColor(color);
+                }
+                
+                // 删除线
+                if (xwpfRun.isStrikeThrough() || xwpfRun.isDoubleStrikeThrough()) {
+                    run.setStrikeThrough(true);
+                }
+                
+                // 上下标
+                VerticalAlign vertAlign = xwpfRun.getSubscript();
+                if (vertAlign != null && vertAlign != VerticalAlign.BASELINE) {
+                    run.setVerticalAlign(vertAlign.name().toLowerCase());
+                }
+                
+                // 高亮颜色
+                if (xwpfRun.isHighlighted()) {
+                    try {
+                        String highlightColor = xwpfRun.getTextHighlightColor().name().toLowerCase();
+                        run.setHighlightColor(highlightColor);
+                    } catch (Exception e) {
+                        run.setHighlightColor("yellow");
+                    }
+                }
+            } catch (Exception e) {
+                log.debug("提取 Run 样式失败: {}", e.getMessage());
+            }
+            
+            runs.add(run);
+        }
+        
+        // 合并相邻的相同样式 Run(优化)
+        return mergeAdjacentRuns(runs);
+    }
+    
+    /**
+     * 合并相邻的相同样式 Run
+     */
+    private List<TextRun> mergeAdjacentRuns(List<TextRun> runs) {
+        if (runs == null || runs.size() <= 1) {
+            return runs;
+        }
+        
+        List<TextRun> merged = new ArrayList<>();
+        TextRun current = runs.get(0);
+        
+        for (int i = 1; i < runs.size(); i++) {
+            TextRun next = runs.get(i);
+            if (isSameStyle(current, next)) {
+                // 合并文本
+                current.setText(current.getText() + next.getText());
+            } else {
+                merged.add(current);
+                current = next;
+            }
+        }
+        merged.add(current);
+        
+        return merged;
+    }
+    
+    /**
+     * 判断两个 Run 样式是否相同
+     */
+    private boolean isSameStyle(TextRun r1, TextRun r2) {
+        return Objects.equals(r1.getFontFamily(), r2.getFontFamily())
+                && Objects.equals(r1.getFontSize(), r2.getFontSize())
+                && Objects.equals(r1.getBold(), r2.getBold())
+                && Objects.equals(r1.getItalic(), r2.getItalic())
+                && Objects.equals(r1.getUnderline(), r2.getUnderline())
+                && Objects.equals(r1.getColor(), r2.getColor())
+                && Objects.equals(r1.getStrikeThrough(), r2.getStrikeThrough())
+                && Objects.equals(r1.getVerticalAlign(), r2.getVerticalAlign())
+                && Objects.equals(r1.getHighlightColor(), r2.getHighlightColor());
+    }
+    
+    /**
+     * 从单元格中提取 Run
+     */
+    private List<TextRun> extractCellRuns(XWPFTableCell cell) {
+        List<TextRun> allRuns = new ArrayList<>();
+        for (XWPFParagraph para : cell.getParagraphs()) {
+            List<TextRun> runs = extractTextRuns(para.getRuns());
+            if (runs != null) {
+                allRuns.addAll(runs);
+            }
+        }
+        return allRuns.isEmpty() ? null : allRuns;
+    }
+    
     /**
      * 结构化提取结果
      */
@@ -381,7 +523,8 @@ public class WordStructuredExtractionService {
         private int index;                    // 元素在文档中的顺序索引
         private String type;                  // paragraph/heading/heading1-9/list_item/image/table/title/toc
         private String content;               // 文本内容(仅文本类型)
-        private Map<String, Object> style;    // 样式信息
+        private Map<String, Object> style;    // 段落级样式信息
+        private List<TextRun> runs;           // 文本片段列表(保留格式)
         
         // 图片相关
         private String imageUrl;              // 图片访问 URL
@@ -399,6 +542,23 @@ public class WordStructuredExtractionService {
         private String tableText;             // 表格文本(用于搜索)
     }
     
+    /**
+     * 文本片段(Run),保留字符级样式
+     */
+    @Data
+    public static class TextRun {
+        private String text;                  // 文本内容
+        private String fontFamily;            // 字体名称
+        private Double fontSize;              // 字号(磅)
+        private Boolean bold;                 // 加粗
+        private Boolean italic;               // 斜体
+        private String underline;             // 下划线类型:single/double/wave/dotted/dashed/none
+        private String color;                 // 字体颜色(十六进制)
+        private Boolean strikeThrough;        // 删除线
+        private String verticalAlign;         // 垂直对齐:baseline/superscript/subscript
+        private String highlightColor;        // 高亮颜色
+    }
+    
     /**
      * 表格单元格
      */
@@ -407,6 +567,7 @@ public class WordStructuredExtractionService {
         private int row;
         private int col;
         private String text;
+        private List<TextRun> runs;           // 单元格内的文本片段
         private Integer colSpan;              // 列合并数
         private Integer rowSpan;              // 行合并数
         private boolean merged;               // 是否为合并单元格

+ 8 - 0
database/migrations/V2026_01_28_01__add_document_elements_runs.sql

@@ -0,0 +1,8 @@
+-- 为 document_elements 表添加 runs 列
+-- 用于存储段落内不同格式的文本片段(TextRun)
+
+ALTER TABLE document_elements 
+ADD COLUMN IF NOT EXISTS runs JSONB;
+
+-- 添加注释
+COMMENT ON COLUMN document_elements.runs IS '文本片段列表(JSON),保留段落内不同格式的文本片段: [{text, fontFamily, fontSize, bold, italic, underline, color, strikeThrough, verticalAlign, highlightColor}, ...]';

+ 202 - 15
frontend/vue-demo/src/views/Editor.vue

@@ -475,22 +475,38 @@ const emptyPlaceholder = `
 function renderStructuredDocument(structuredDoc) {
   const blocks = structuredDoc.blocks || []
   const images = structuredDoc.images || []
+  const paragraphs = structuredDoc.paragraphs || []
   
-  // 如果没有图片,直接渲染 blocks
-  if (images.length === 0) {
-    return blocks
-      .map(block => block.markedHtml || block.html || block.plainText || '')
-      .join('')
+  // 优先使用 paragraphs(包含格式信息),如果存在的话
+  const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
+  
+  // 将所有元素合并
+  const allElements = []
+  
+  // 添加 blocks(带实体标记)或 paragraphs(带格式)
+  if (hasParagraphsWithRuns) {
+    // 使用 paragraphs 渲染(保留格式)
+    paragraphs.forEach(para => {
+      allElements.push({
+        type: 'paragraph',
+        index: para.index,
+        html: renderParagraphWithRuns(para)
+      })
+    })
+  } else {
+    // 使用 blocks 渲染(带实体标记)
+    blocks.forEach(block => {
+      allElements.push({
+        type: 'block',
+        index: block.index,
+        html: block.markedHtml || block.html || block.plainText || ''
+      })
+    })
   }
   
-  // 将 blocks 和 images 合并,按 index 排序
-  const allElements = [
-    ...blocks.map(block => ({
-      type: 'block',
-      index: block.index,
-      html: block.markedHtml || block.html || block.plainText || ''
-    })),
-    ...images.map(img => ({
+  // 添加图片
+  images.forEach(img => {
+    allElements.push({
       type: 'image',
       index: img.index,
       html: `<div class="doc-image" style="text-align: center; margin: 16px 0;">
@@ -500,8 +516,8 @@ function renderStructuredDocument(structuredDoc) {
              ${img.height ? `height="${img.height}"` : ''} />
         ${img.alt ? `<p class="image-caption" style="color: #666; font-size: 12px; margin-top: 8px;">${img.alt}</p>` : ''}
       </div>`
-    }))
-  ]
+    })
+  })
   
   // 按 index 排序
   allElements.sort((a, b) => (a.index || 0) - (b.index || 0))
@@ -510,6 +526,177 @@ function renderStructuredDocument(structuredDoc) {
   return allElements.map(el => el.html).join('')
 }
 
+/**
+ * 渲染带格式的段落(使用 runs)
+ */
+function renderParagraphWithRuns(para) {
+  if (!para.runs || para.runs.length === 0) {
+    // 没有 runs,使用纯文本
+    const content = escapeHtml(para.content || '')
+    return wrapWithParagraphTag(content, para.type, para.style)
+  }
+  
+  // 渲染每个 run
+  const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
+  return wrapWithParagraphTag(runsHtml, para.type, para.style)
+}
+
+/**
+ * 渲染单个文本片段(Run)
+ */
+function renderTextRun(run) {
+  if (!run || !run.text) return ''
+  
+  let text = escapeHtml(run.text)
+  const styles = []
+  
+  // 字体
+  if (run.fontFamily) {
+    styles.push(`font-family:${run.fontFamily}`)
+  }
+  
+  // 字号
+  if (run.fontSize && run.fontSize > 0) {
+    styles.push(`font-size:${run.fontSize}pt`)
+  }
+  
+  // 颜色
+  if (run.color) {
+    const color = run.color.startsWith('#') ? run.color : `#${run.color}`
+    styles.push(`color:${color}`)
+  }
+  
+  // 高亮/背景色
+  if (run.highlightColor) {
+    const bgColor = getHighlightColor(run.highlightColor)
+    styles.push(`background-color:${bgColor}`)
+  }
+  
+  // 加粗
+  if (run.bold) {
+    styles.push('font-weight:bold')
+  }
+  
+  // 斜体
+  if (run.italic) {
+    styles.push('font-style:italic')
+  }
+  
+  // 下划线和删除线
+  const textDecorations = []
+  if (run.underline && run.underline !== 'none') {
+    const underlineStyle = run.underline === 'double' ? 'double' : 
+                           run.underline === 'wave' || run.underline === 'wavy' ? 'wavy' :
+                           run.underline === 'dotted' ? 'dotted' :
+                           run.underline === 'dashed' ? 'dashed' : 'solid'
+    textDecorations.push(`underline ${underlineStyle}`)
+  }
+  if (run.strikeThrough) {
+    textDecorations.push('line-through')
+  }
+  if (textDecorations.length > 0) {
+    styles.push(`text-decoration:${textDecorations.join(' ')}`)
+  }
+  
+  // 上下标
+  if (run.verticalAlign === 'superscript') {
+    return `<sup>${text}</sup>`
+  } else if (run.verticalAlign === 'subscript') {
+    return `<sub>${text}</sub>`
+  }
+  
+  // 如果没有样式,直接返回文本
+  if (styles.length === 0) {
+    return text
+  }
+  
+  return `<span style="${styles.join(';')}">${text}</span>`
+}
+
+/**
+ * 获取高亮颜色对应的 CSS 颜色
+ */
+function getHighlightColor(colorName) {
+  const colors = {
+    'yellow': '#ffff00',
+    'green': '#00ff00',
+    'cyan': '#00ffff',
+    'magenta': '#ff00ff',
+    'blue': '#0000ff',
+    'red': '#ff0000',
+    'darkblue': '#000080',
+    'darkcyan': '#008080',
+    'darkgreen': '#008000',
+    'darkmagenta': '#800080',
+    'darkred': '#800000',
+    'darkyellow': '#808000',
+    'darkgray': '#808080',
+    'lightgray': '#c0c0c0',
+    'black': '#000000'
+  }
+  return colors[colorName.toLowerCase()] || colorName
+}
+
+/**
+ * 用段落标签包裹内容
+ */
+function wrapWithParagraphTag(content, type, style) {
+  // 段落样式
+  const styleAttrs = []
+  if (style) {
+    if (style.alignment) {
+      styleAttrs.push(`text-align:${style.alignment}`)
+    }
+    if (style.indentLeft) {
+      styleAttrs.push(`padding-left:${style.indentLeft / 20}pt`)
+    }
+    if (style.indentFirstLine) {
+      styleAttrs.push(`text-indent:${style.indentFirstLine / 20}pt`)
+    }
+    if (style.lineSpacing) {
+      styleAttrs.push(`line-height:${style.lineSpacing}`)
+    }
+  }
+  
+  const styleAttr = styleAttrs.length > 0 ? ` style="${styleAttrs.join(';')}"` : ''
+  
+  switch (type) {
+    case 'heading1':
+      return `<h1${styleAttr}>${content}</h1>`
+    case 'heading2':
+      return `<h2${styleAttr}>${content}</h2>`
+    case 'heading3':
+      return `<h3${styleAttr}>${content}</h3>`
+    case 'heading':
+      return `<h2${styleAttr}>${content}</h2>`
+    case 'bullet':
+    case 'list_item':
+      return `<li${styleAttr}>${content}</li>`
+    case 'ordered':
+      return `<li${styleAttr}>${content}</li>`
+    case 'quote':
+      return `<blockquote${styleAttr}>${content}</blockquote>`
+    case 'code':
+      return `<pre><code>${content}</code></pre>`
+    case 'title':
+      return `<h1 class="doc-title"${styleAttr}>${content}</h1>`
+    default:
+      return `<p${styleAttr}>${content}</p>`
+  }
+}
+
+/**
+ * HTML 转义
+ */
+function escapeHtml(text) {
+  if (!text) return ''
+  return text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
 // 计算属性
 const groupedVariables = computed(() => {
   const groups = {}