package pdf import ( "context" "encoding/json" "fmt" "html" "math" "os" "path/filepath" "regexp" "strings" "github.com/jung-kurt/gofpdf/v2" "github.com/shopspring/decimal" "go.uber.org/zap" "tyapi-server/internal/domains/product/entities" ) // PDFGenerator PDF生成器 type PDFGenerator struct { logger *zap.Logger chineseFont string logoPath string watermarkText string } // NewPDFGenerator 创建PDF生成器 func NewPDFGenerator(logger *zap.Logger) *PDFGenerator { gen := &PDFGenerator{ logger: logger, watermarkText: "海南海宇大数据有限公司", } // 尝试注册中文字体 chineseFont := gen.registerChineseFont() gen.chineseFont = chineseFont // 查找logo文件 gen.findLogo() return gen } // registerChineseFont 注册中文字体 // gofpdf v2 默认支持 UTF-8,但需要添加支持中文的字体文件 func (g *PDFGenerator) registerChineseFont() string { // 返回字体名称标识,实际在generatePDF中注册 return "ChineseFont" } // findLogo 查找logo文件(仅从resources/pdf加载) func (g *PDFGenerator) findLogo() { // 获取resources/pdf目录(使用统一的资源路径查找函数) resourcesPDFDir := GetResourcesPDFDir() logoPath := filepath.Join(resourcesPDFDir, "logo.png") // 检查文件是否存在 if _, err := os.Stat(logoPath); err == nil { g.logoPath = logoPath return } // 只记录关键错误 g.logger.Warn("未找到logo文件", zap.String("path", logoPath)) } // GenerateProductPDF 为产品生成PDF文档(接受响应类型,内部转换) func (g *PDFGenerator) GenerateProductPDF(ctx context.Context, productID string, productName, productCode, description, content string, price float64, doc *entities.ProductDocumentation) ([]byte, error) { // 构建临时的 Product entity(仅用于PDF生成) product := &entities.Product{ ID: productID, Name: productName, Code: productCode, Description: description, Content: content, } // 如果有价格信息,设置价格 if price > 0 { product.Price = decimal.NewFromFloat(price) } return g.generatePDF(product, doc) } // GenerateProductPDFFromEntity 从entity类型生成PDF(推荐使用) func (g *PDFGenerator) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) { return g.generatePDF(product, doc) } // generatePDF 内部PDF生成方法 // 现在使用重构后的模块化组件 func (g *PDFGenerator) generatePDF(product *entities.Product, doc *entities.ProductDocumentation) (result []byte, err error) { defer func() { if r := recover(); r != nil { g.logger.Error("PDF生成过程中发生panic", zap.String("product_id", product.ID), zap.String("product_name", product.Name), zap.Any("panic_value", r), ) // 将panic转换为error,而不是重新抛出 if e, ok := r.(error); ok { err = fmt.Errorf("PDF生成panic: %w", e) } else { err = fmt.Errorf("PDF生成panic: %v", r) } result = nil } }() g.logger.Info("开始生成PDF(使用重构后的模块化组件)", zap.String("product_id", product.ID), zap.String("product_name", product.Name), zap.Bool("has_doc", doc != nil), ) // 使用重构后的生成器 refactoredGen := NewPDFGeneratorRefactored(g.logger) return refactoredGen.GenerateProductPDFFromEntity(context.Background(), product, doc) } // addFirstPage 添加第一页(封面页 - 产品功能简述) func (g *PDFGenerator) addFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) { pdf.AddPage() // 添加页眉(logo和文字) g.addHeader(pdf, chineseFontAvailable) // 添加水印 g.addWatermark(pdf, chineseFontAvailable) // 封面页布局 - 居中显示 pageWidth, pageHeight := pdf.GetPageSize() // 标题区域(页面中上部) pdf.SetY(80) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 32) } else { pdf.SetFont("Arial", "B", 32) } _, lineHt := pdf.GetFontSize() // 清理产品名称中的无效字符 cleanName := g.cleanText(product.Name) pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "") // 添加"接口文档"副标题 pdf.Ln(10) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 18) } else { pdf.SetFont("Arial", "", 18) } _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "") // 分隔线 pdf.Ln(20) pdf.SetLineWidth(0.5) pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY()) // 产品编码(居中) pdf.Ln(30) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 14) } else { pdf.SetFont("Arial", "", 14) } _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "") // 产品描述(居中显示,段落格式) if product.Description != "" { pdf.Ln(25) desc := g.stripHTML(product.Description) desc = g.cleanText(desc) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 14) } else { pdf.SetFont("Arial", "", 14) } _, lineHt = pdf.GetFontSize() // 居中对齐的MultiCell(通过计算宽度实现) descWidth := pageWidth * 0.7 descLines := pdf.SplitText(desc, descWidth) currentX := (pageWidth - descWidth) / 2 for _, line := range descLines { pdf.SetX(currentX) pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "") } } // 产品详情(如果存在) if product.Content != "" { pdf.Ln(20) content := g.stripHTML(product.Content) content = g.cleanText(content) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 12) } else { pdf.SetFont("Arial", "", 12) } _, lineHt = pdf.GetFontSize() contentWidth := pageWidth * 0.7 contentLines := pdf.SplitText(content, contentWidth) currentX := (pageWidth - contentWidth) / 2 for _, line := range contentLines { pdf.SetX(currentX) pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "") } } // 底部信息(价格等) if !product.Price.IsZero() { pdf.SetY(pageHeight - 60) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 12) } else { pdf.SetFont("Arial", "", 12) } _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, fmt.Sprintf("价格:%s 元", product.Price.String()), "", 1, "C", false, 0, "") } } // addDocumentationPages 添加接口文档页面 func (g *PDFGenerator) addDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { // 创建自定义的AddPage函数,确保每页都有水印 addPageWithWatermark := func() { pdf.AddPage() g.addHeader(pdf, chineseFontAvailable) g.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 } addPageWithWatermark() pdf.SetY(45) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 18) } else { pdf.SetFont("Arial", "B", 18) } _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "") // 请求URL pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 12) } else { pdf.SetFont("Arial", "B", 12) } pdf.CellFormat(0, lineHt, "请求URL:", "", 1, "L", false, 0, "") // URL也需要使用中文字体(可能包含中文字符),但用Courier字体保持等宽效果 // 先清理URL中的乱码 cleanURL := g.cleanText(doc.RequestURL) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) // 使用中文字体 } else { pdf.SetFont("Courier", "", 10) } pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) // 请求方法 pdf.Ln(5) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 12) } else { pdf.SetFont("Arial", "B", 12) } pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "") // 基本信息 if doc.BasicInfo != "" { pdf.Ln(8) g.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable) } // 请求参数 if doc.RequestParams != "" { pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 14) } else { pdf.SetFont("Arial", "B", 14) } _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "") // 处理请求参数:直接解析所有表格,确保表格能够正确渲染 g.processRequestParams(pdf, doc.RequestParams, chineseFontAvailable, lineHt) // 生成JSON示例 if jsonExample := g.generateJSONExample(doc.RequestParams); jsonExample != "" { pdf.Ln(5) pdf.SetTextColor(0, 0, 0) // 确保深黑色 if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 14) } else { pdf.SetFont("Arial", "B", 14) } pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") // JSON中可能包含中文值,必须使用中文字体 if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) // 使用中文字体显示JSON(支持中文) } else { pdf.SetFont("Courier", "", 9) } pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) } } // 响应示例 if doc.ResponseExample != "" { pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 14) } else { pdf.SetFont("Arial", "B", 14) } _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") // 处理响应示例:不按markdown标题分级,直接解析所有表格 // 确保所有数据字段都显示在表格中 g.processResponseExample(pdf, doc.ResponseExample, chineseFontAvailable, lineHt) } // 返回字段 if doc.ResponseFields != "" { pdf.Ln(8) // 先将数据格式化为标准的markdown表格格式 formattedFields := g.formatContentAsMarkdownTable(doc.ResponseFields) g.addSection(pdf, "返回字段", formattedFields, chineseFontAvailable) } // 错误代码 if doc.ErrorCodes != "" { pdf.Ln(8) g.addSection(pdf, "错误代码", doc.ErrorCodes, chineseFontAvailable) } } // addSection 添加章节 func (g *PDFGenerator) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) { _, lineHt := pdf.GetFontSize() if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 14) } else { pdf.SetFont("Arial", "B", 14) } pdf.CellFormat(0, lineHt, title+":", "", 1, "L", false, 0, "") // 先将内容格式化为标准的markdown表格格式(如果还不是) content = g.formatContentAsMarkdownTable(content) // 先尝试提取JSON(如果是代码块格式) if jsonContent := g.extractJSON(content); jsonContent != "" { // 格式化JSON formattedJSON, err := g.formatJSON(jsonContent) if err == nil { jsonContent = formattedJSON } pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) } else { pdf.SetFont("Courier", "", 9) } pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false) } else { // 按#号标题分割内容,每个标题下的内容单独处理 sections := g.splitByMarkdownHeaders(content) if len(sections) > 0 { // 如果有多个章节,逐个处理 for i, section := range sections { if i > 0 { pdf.Ln(5) // 章节之间的间距 } // 如果有标题,先显示标题 if section.Title != "" { titleLevel := section.Level fontSize := 14.0 - float64(titleLevel-2)*2 // ## 是14, ### 是12, #### 是10 if fontSize < 10 { fontSize = 10 } if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", fontSize) } else { pdf.SetFont("Arial", "B", fontSize) } pdf.SetTextColor(0, 0, 0) // 清理标题中的#号 cleanTitle := strings.TrimSpace(strings.TrimLeft(section.Title, "#")) pdf.CellFormat(0, lineHt*1.2, cleanTitle, "", 1, "L", false, 0, "") pdf.Ln(3) } // 处理该章节的内容(可能是表格或文本) g.processSectionContent(pdf, section.Content, chineseFontAvailable, lineHt) } } else { // 如果没有标题分割,直接处理整个内容 g.processSectionContent(pdf, content, chineseFontAvailable, lineHt) } } } // MarkdownSection 已在 markdown_processor.go 中定义 // splitByMarkdownHeaders 按markdown标题分割内容 func (g *PDFGenerator) splitByMarkdownHeaders(content string) []MarkdownSection { lines := strings.Split(content, "\n") var sections []MarkdownSection var currentSection MarkdownSection var currentContent []string // 标题正则:匹配 #, ##, ###, #### 等 headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`) for _, line := range lines { trimmedLine := strings.TrimSpace(line) // 检查是否是标题行 if matches := headerRegex.FindStringSubmatch(trimmedLine); matches != nil { // 如果之前有内容,先保存之前的章节 if currentSection.Title != "" || len(currentContent) > 0 { if currentSection.Title != "" { currentSection.Content = strings.Join(currentContent, "\n") sections = append(sections, currentSection) } } // 开始新章节 level := len(matches[1]) // #号的数量 currentSection = MarkdownSection{ Title: trimmedLine, Level: level, Content: "", } currentContent = []string{} } else { // 普通内容行,添加到当前章节 currentContent = append(currentContent, line) } } // 保存最后一个章节 if currentSection.Title != "" || len(currentContent) > 0 { if currentSection.Title != "" { currentSection.Content = strings.Join(currentContent, "\n") sections = append(sections, currentSection) } else if len(currentContent) > 0 { // 如果没有标题,但开头有内容,作为第一个章节 sections = append(sections, MarkdownSection{ Title: "", Level: 0, Content: strings.Join(currentContent, "\n"), }) } } return sections } // formatContentAsMarkdownTable 将数据库中的数据格式化为标准的markdown表格格式 // 注意:数据库中的数据通常不是JSON格式(除了代码块中的示例),主要是文本或markdown格式 func (g *PDFGenerator) formatContentAsMarkdownTable(content string) string { if strings.TrimSpace(content) == "" { return content } // 如果内容已经是markdown表格格式(包含|符号),直接返回 if strings.Contains(content, "|") { // 检查是否已经是有效的markdown表格 lines := strings.Split(content, "\n") hasTableFormat := false for _, line := range lines { trimmed := strings.TrimSpace(line) // 跳过代码块中的内容 if strings.HasPrefix(trimmed, "```") { continue } if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "#") { hasTableFormat = true break } } if hasTableFormat { return content } } // 提取代码块(保留代码块不变) codeBlocks := g.extractCodeBlocks(content) // 移除代码块,只处理非代码块部分 contentWithoutCodeBlocks := g.removeCodeBlocks(content) // 如果移除代码块后内容为空,说明只有代码块,直接返回原始内容 if strings.TrimSpace(contentWithoutCodeBlocks) == "" { return content } // 尝试解析非代码块部分为JSON数组(仅当内容看起来像JSON时) trimmedContent := strings.TrimSpace(contentWithoutCodeBlocks) // 检查是否看起来像JSON(以[或{开头) if strings.HasPrefix(trimmedContent, "[") || strings.HasPrefix(trimmedContent, "{") { // 尝试解析为JSON数组 var requestParams []map[string]interface{} if err := json.Unmarshal([]byte(trimmedContent), &requestParams); err == nil && len(requestParams) > 0 { // 成功解析为JSON数组,转换为markdown表格 tableContent := g.jsonArrayToMarkdownTable(requestParams) // 如果有代码块,在表格后添加代码块 if len(codeBlocks) > 0 { return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n") } return tableContent } // 尝试解析为单个JSON对象 var singleObj map[string]interface{} if err := json.Unmarshal([]byte(trimmedContent), &singleObj); err == nil { // 检查是否是包含数组字段的对象 if params, ok := singleObj["params"].([]interface{}); ok { // 转换为map数组 paramMaps := make([]map[string]interface{}, 0, len(params)) for _, p := range params { if pm, ok := p.(map[string]interface{}); ok { paramMaps = append(paramMaps, pm) } } if len(paramMaps) > 0 { tableContent := g.jsonArrayToMarkdownTable(paramMaps) // 如果有代码块,在表格后添加代码块 if len(codeBlocks) > 0 { return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n") } return tableContent } } if fields, ok := singleObj["fields"].([]interface{}); ok { // 转换为map数组 fieldMaps := make([]map[string]interface{}, 0, len(fields)) for _, f := range fields { if fm, ok := f.(map[string]interface{}); ok { fieldMaps = append(fieldMaps, fm) } } if len(fieldMaps) > 0 { tableContent := g.jsonArrayToMarkdownTable(fieldMaps) // 如果有代码块,在表格后添加代码块 if len(codeBlocks) > 0 { return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n") } return tableContent } } } } // 如果无法解析为JSON,返回原始内容(保留代码块) return content } // extractCodeBlocks 提取内容中的所有代码块 func (g *PDFGenerator) extractCodeBlocks(content string) []string { var codeBlocks []string lines := strings.Split(content, "\n") inCodeBlock := false var currentBlock []string for _, line := range lines { trimmed := strings.TrimSpace(line) // 检查是否是代码块开始 if strings.HasPrefix(trimmed, "```") { if inCodeBlock { // 代码块结束 currentBlock = append(currentBlock, line) codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n")) currentBlock = []string{} inCodeBlock = false } else { // 代码块开始 inCodeBlock = true currentBlock = []string{line} } } else if inCodeBlock { // 在代码块中 currentBlock = append(currentBlock, line) } } // 如果代码块没有正确关闭,也添加进去 if inCodeBlock && len(currentBlock) > 0 { codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n")) } return codeBlocks } // removeCodeBlocks 移除内容中的所有代码块 func (g *PDFGenerator) removeCodeBlocks(content string) string { lines := strings.Split(content, "\n") var result []string inCodeBlock := false for _, line := range lines { trimmed := strings.TrimSpace(line) // 检查是否是代码块开始或结束 if strings.HasPrefix(trimmed, "```") { inCodeBlock = !inCodeBlock continue // 跳过代码块的标记行 } // 如果不在代码块中,保留这一行 if !inCodeBlock { result = append(result, line) } } return strings.Join(result, "\n") } // jsonArrayToMarkdownTable 将JSON数组转换为标准的markdown表格 func (g *PDFGenerator) jsonArrayToMarkdownTable(data []map[string]interface{}) string { if len(data) == 0 { return "" } var result strings.Builder // 收集所有可能的列名(保持原始顺序) // 使用map记录是否已添加,使用slice保持顺序 columnSet := make(map[string]bool) columns := make([]string, 0) // 遍历所有数据行,按第一次出现的顺序收集列名 for _, row := range data { for key := range row { if !columnSet[key] { columns = append(columns, key) columnSet[key] = true } } } if len(columns) == 0 { return "" } // 构建表头(直接使用原始列名,不做映射) result.WriteString("|") for _, col := range columns { result.WriteString(" ") result.WriteString(col) // 直接使用原始列名 result.WriteString(" |") } result.WriteString("\n") // 构建分隔行 result.WriteString("|") for range columns { result.WriteString(" --- |") } result.WriteString("\n") // 构建数据行 for _, row := range data { result.WriteString("|") for _, col := range columns { result.WriteString(" ") value := g.formatCellValue(row[col]) result.WriteString(value) result.WriteString(" |") } result.WriteString("\n") } return result.String() } // formatColumnName 格式化列名(直接返回原始列名,不做映射) // 保持数据库原始数据的列名,不进行转换 func (g *PDFGenerator) formatColumnName(name string) string { // 直接返回原始列名,保持数据库数据的原始格式 return name } // formatCellValue 格式化单元格值 func (g *PDFGenerator) formatCellValue(value interface{}) string { if value == nil { return "" } switch v := value.(type) { case string: // 清理字符串,移除换行符和多余空格 v = strings.ReplaceAll(v, "\n", " ") v = strings.ReplaceAll(v, "\r", " ") v = strings.TrimSpace(v) // 转义markdown特殊字符 v = strings.ReplaceAll(v, "|", "\\|") return v case bool: if v { return "是" } return "否" case float64: // 如果是整数,不显示小数点 if v == float64(int64(v)) { return fmt.Sprintf("%.0f", v) } return fmt.Sprintf("%g", v) case int, int8, int16, int32, int64: return fmt.Sprintf("%d", v) case uint, uint8, uint16, uint32, uint64: return fmt.Sprintf("%d", v) default: // 对于其他类型,转换为字符串 str := fmt.Sprintf("%v", v) str = strings.ReplaceAll(str, "\n", " ") str = strings.ReplaceAll(str, "\r", " ") str = strings.ReplaceAll(str, "|", "\\|") return strings.TrimSpace(str) } } // processRequestParams 处理请求参数:直接解析所有表格,确保表格能够正确渲染 func (g *PDFGenerator) processRequestParams(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) { // 先将数据格式化为标准的markdown表格格式 processedContent := g.formatContentAsMarkdownTable(content) // 解析并显示所有表格(不按标题分组) // 将内容按表格分割,找到所有表格块 allTables := g.extractAllTables(processedContent) if len(allTables) > 0 { // 有表格,逐个渲染 for i, tableBlock := range allTables { if i > 0 { pdf.Ln(5) // 表格之间的间距 } // 渲染表格前的说明文字(包括标题) if tableBlock.BeforeText != "" { beforeText := tableBlock.BeforeText // 处理标题和文本 g.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt) pdf.Ln(3) } // 渲染表格 if len(tableBlock.TableData) > 0 && g.isValidTable(tableBlock.TableData) { g.addTable(pdf, tableBlock.TableData, chineseFontAvailable) } // 渲染表格后的说明文字 if tableBlock.AfterText != "" { afterText := g.stripHTML(tableBlock.AfterText) afterText = g.cleanText(afterText) if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } } } else { // 没有表格,显示为文本 text := g.stripHTML(processedContent) text = g.cleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } } // processResponseExample 处理响应示例:不按markdown标题分级,直接解析所有表格,但保留标题显示 func (g *PDFGenerator) processResponseExample(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) { // 先将数据格式化为标准的markdown表格格式 processedContent := g.formatContentAsMarkdownTable(content) // 尝试提取JSON内容(如果存在代码块) jsonContent := g.extractJSON(processedContent) if jsonContent != "" { pdf.SetTextColor(0, 0, 0) formattedJSON, err := g.formatJSON(jsonContent) if err == nil { jsonContent = formattedJSON } if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) } else { pdf.SetFont("Courier", "", 9) } pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) pdf.Ln(5) } // 解析并显示所有表格(不按标题分组) // 将内容按表格分割,找到所有表格块 allTables := g.extractAllTables(processedContent) if len(allTables) > 0 { // 有表格,逐个渲染 for i, tableBlock := range allTables { if i > 0 { pdf.Ln(5) // 表格之间的间距 } // 渲染表格前的说明文字(包括标题) if tableBlock.BeforeText != "" { beforeText := tableBlock.BeforeText // 处理标题和文本 g.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt) pdf.Ln(3) } // 渲染表格 if len(tableBlock.TableData) > 0 && g.isValidTable(tableBlock.TableData) { g.addTable(pdf, tableBlock.TableData, chineseFontAvailable) } // 渲染表格后的说明文字 if tableBlock.AfterText != "" { afterText := g.stripHTML(tableBlock.AfterText) afterText = g.cleanText(afterText) if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } } } else { // 没有表格,显示为文本 text := g.stripHTML(processedContent) text = g.cleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } } // TableBlock 已在 table_parser.go 中定义 // extractAllTables 从内容中提取所有表格块(保留标题作为BeforeText的一部分) func (g *PDFGenerator) extractAllTables(content string) []TableBlock { var blocks []TableBlock lines := strings.Split(content, "\n") var currentTableLines []string var beforeTableLines []string inTable := false lastTableEnd := -1 for i, line := range lines { trimmedLine := strings.TrimSpace(line) // 保留标题行,不跳过(标题会作为BeforeText的一部分) // 检查是否是表格行(包含|符号,且不是分隔行) isSeparator := false if strings.Contains(trimmedLine, "|") && strings.Contains(trimmedLine, "-") { // 检查是否是分隔行(只包含|、-、:、空格) isSeparator = true for _, r := range trimmedLine { if r != '|' && r != '-' && r != ':' && r != ' ' { isSeparator = false break } } } isTableLine := strings.Contains(trimmedLine, "|") && !isSeparator if isTableLine { if !inTable { // 开始新表格,保存之前的文本(包括标题) beforeTableLines = []string{} if lastTableEnd >= 0 { beforeTableLines = lines[lastTableEnd+1 : i] } else { beforeTableLines = lines[0:i] } inTable = true currentTableLines = []string{} } currentTableLines = append(currentTableLines, line) } else { if inTable { // 表格可能结束了(遇到空行或非表格内容) // 检查是否是连续的空行(可能是表格真的结束了) if trimmedLine == "" { // 空行,继续收集(可能是表格内的空行) currentTableLines = append(currentTableLines, line) } else { // 非空行,表格结束 // 解析并保存表格 tableContent := strings.Join(currentTableLines, "\n") tableData := g.parseMarkdownTable(tableContent) if len(tableData) > 0 && g.isValidTable(tableData) { block := TableBlock{ BeforeText: strings.Join(beforeTableLines, "\n"), TableData: tableData, AfterText: "", } blocks = append(blocks, block) lastTableEnd = i - 1 } currentTableLines = []string{} beforeTableLines = []string{} inTable = false } } else { // 不在表格中,这些行(包括标题)会被收集到下一个表格的BeforeText中 // 不需要特殊处理,它们会在开始新表格时被收集 } } } // 处理最后一个表格(如果还在表格中) if inTable && len(currentTableLines) > 0 { tableContent := strings.Join(currentTableLines, "\n") tableData := g.parseMarkdownTable(tableContent) if len(tableData) > 0 && g.isValidTable(tableData) { block := TableBlock{ BeforeText: strings.Join(beforeTableLines, "\n"), TableData: tableData, AfterText: "", } blocks = append(blocks, block) } } return blocks } // renderTextWithTitles 渲染包含markdown标题的文本 func (g *PDFGenerator) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) { lines := strings.Split(text, "\n") for _, line := range lines { trimmedLine := strings.TrimSpace(line) // 检查是否是标题行 if strings.HasPrefix(trimmedLine, "#") { // 计算标题级别 level := 0 for _, r := range trimmedLine { if r == '#' { level++ } else { break } } // 提取标题文本(移除#号) titleText := strings.TrimSpace(trimmedLine[level:]) if titleText == "" { continue } // 根据级别设置字体大小 fontSize := 14.0 - float64(level-2)*2 if fontSize < 10 { fontSize = 10 } if fontSize > 16 { fontSize = 16 } // 渲染标题 pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", fontSize) } else { pdf.SetFont("Arial", "B", fontSize) } _, titleLineHt := pdf.GetFontSize() pdf.CellFormat(0, titleLineHt*1.2, titleText, "", 1, "L", false, 0, "") pdf.Ln(2) } else if strings.TrimSpace(line) != "" { // 普通文本行(只去除HTML标签,保留markdown格式) cleanText := g.stripHTML(line) cleanText = g.cleanTextPreservingMarkdown(cleanText) if strings.TrimSpace(cleanText) != "" { pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false) } } else { // 空行,添加间距 pdf.Ln(2) } } } // processSectionContent 处理单个章节的内容(解析表格或显示文本) func (g *PDFGenerator) processSectionContent(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) { // 尝试解析markdown表格 tableData := g.parseMarkdownTable(content) // 记录解析结果用于调试 contentPreview := content if len(contentPreview) > 100 { contentPreview = contentPreview[:100] + "..." } g.logger.Info("解析表格结果", zap.Int("table_rows", len(tableData)), zap.Bool("is_valid", g.isValidTable(tableData)), zap.String("content_preview", contentPreview)) // 检查内容是否包含表格标记(|符号) hasTableMarkers := strings.Contains(content, "|") // 如果解析出了表格数据(即使验证失败),或者内容包含表格标记,都尝试渲染表格 // 放宽条件:支持只有表头的表格(单行表格) if len(tableData) >= 1 && hasTableMarkers { // 如果表格数据不够完整,但包含表格标记,尝试强制解析 if !g.isValidTable(tableData) && hasTableMarkers && len(tableData) < 2 { g.logger.Warn("表格验证失败但包含表格标记,尝试重新解析", zap.Int("rows", len(tableData))) // 可以在这里添加更宽松的解析逻辑 } // 如果表格有效,或者至少有表头,都尝试渲染 if g.isValidTable(tableData) { // 如果是有效的表格,先检查表格前后是否有说明文字 // 提取表格前后的文本(用于显示说明) lines := strings.Split(content, "\n") var beforeTable []string var afterTable []string inTable := false tableStartLine := -1 tableEndLine := -1 // 找到表格的起始和结束行 usePipeDelimiter := false for _, line := range lines { if strings.Contains(strings.TrimSpace(line), "|") { usePipeDelimiter = true break } } for i, line := range lines { trimmedLine := strings.TrimSpace(line) if usePipeDelimiter && strings.Contains(trimmedLine, "|") { if !inTable { tableStartLine = i inTable = true } tableEndLine = i } else if inTable && usePipeDelimiter && !strings.Contains(trimmedLine, "|") { // 表格可能结束了 if strings.HasPrefix(trimmedLine, "```") { tableEndLine = i - 1 break } } } // 提取表格前的文本 if tableStartLine > 0 { beforeTable = lines[0:tableStartLine] } // 提取表格后的文本 if tableEndLine >= 0 && tableEndLine < len(lines)-1 { afterTable = lines[tableEndLine+1:] } // 显示表格前的说明文字 if len(beforeTable) > 0 { beforeText := strings.Join(beforeTable, "\n") beforeText = g.stripHTML(beforeText) beforeText = g.cleanText(beforeText) if strings.TrimSpace(beforeText) != "" { pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false) pdf.Ln(3) } } // 渲染表格 g.addTable(pdf, tableData, chineseFontAvailable) // 显示表格后的说明文字 if len(afterTable) > 0 { afterText := strings.Join(afterTable, "\n") afterText = g.stripHTML(afterText) afterText = g.cleanText(afterText) if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } } } else { // 如果不是有效表格,显示为文本(完整显示markdown内容) pdf.SetTextColor(0, 0, 0) if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 10) } else { pdf.SetFont("Arial", "", 10) } text := g.stripHTML(content) text = g.cleanText(text) // 清理无效字符,保留中文 // 如果文本不为空,显示它 if strings.TrimSpace(text) != "" { pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } } // isValidTable 验证表格是否有效 func (g *PDFGenerator) isValidTable(tableData [][]string) bool { // 至少需要表头(放宽条件,支持只有表头的情况) if len(tableData) < 1 { return false } // 表头必须至少1列(支持单列表格) header := tableData[0] if len(header) < 1 { return false } // 检查表头是否包含有效内容(不是全部为空) hasValidHeader := false for _, cell := range header { if strings.TrimSpace(cell) != "" { hasValidHeader = true break } } if !hasValidHeader { return false } // 如果只有表头,也认为是有效表格 if len(tableData) == 1 { return true } // 检查数据行是否包含有效内容 hasValidData := false validRowCount := 0 for i := 1; i < len(tableData); i++ { row := tableData[i] // 检查这一行是否包含有效内容 rowHasContent := false for _, cell := range row { if strings.TrimSpace(cell) != "" { rowHasContent = true break } } if rowHasContent { hasValidData = true validRowCount++ } } // 如果有数据行,至少需要一行有效数据 if len(tableData) > 1 && !hasValidData { return false } // 如果有效行数过多(超过100行),可能是解析错误,不认为是有效表格 if validRowCount > 100 { g.logger.Warn("表格行数过多,可能是解析错误", zap.Int("row_count", validRowCount)) return false } return true } // addTable 添加表格 func (g *PDFGenerator) addTable(pdf *gofpdf.Fpdf, tableData [][]string, chineseFontAvailable bool) { if len(tableData) == 0 { return } // 再次验证表格有效性,避免渲染无效表格 if !g.isValidTable(tableData) { g.logger.Warn("尝试渲染无效表格,跳过", zap.Int("rows", len(tableData))) return } // 支持只有表头的表格(单行表格) if len(tableData) == 1 { g.logger.Info("渲染单行表格(只有表头)", zap.Int("cols", len(tableData[0]))) } _, lineHt := pdf.GetFontSize() pdf.SetFont("Arial", "", 9) // 计算列宽(简单平均分配) pageWidth, _ := pdf.GetPageSize() pageWidth = pageWidth - 40 // 减去左右边距 numCols := len(tableData[0]) colWidth := pageWidth / float64(numCols) // 限制列宽,避免过窄 if colWidth < 30 { colWidth = 30 } // 绘制表头 header := tableData[0] pdf.SetFillColor(74, 144, 226) // 蓝色背景 pdf.SetTextColor(255, 255, 255) // 白色文字 if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 9) } else { pdf.SetFont("Arial", "B", 9) } // 清理表头文本(只清理无效字符,保留markdown格式) for i, cell := range header { header[i] = g.cleanTextPreservingMarkdown(cell) } for _, cell := range header { pdf.CellFormat(colWidth, lineHt*1.5, cell, "1", 0, "C", true, 0, "") } pdf.Ln(-1) // 绘制数据行 pdf.SetFillColor(245, 245, 220) // 米色背景 pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰 if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) // 增大字体 } else { pdf.SetFont("Arial", "", 9) } _, lineHt = pdf.GetFontSize() for i := 1; i < len(tableData); i++ { row := tableData[i] fill := (i % 2) == 0 // 交替填充 // 计算这一行的起始Y坐标 startY := pdf.GetY() // 设置字体以计算文本宽度和高度 if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) } else { pdf.SetFont("Arial", "", 9) } _, cellLineHt := pdf.GetFontSize() // 先遍历一次,计算每列需要的最大高度 maxCellHeight := cellLineHt * 1.5 // 最小高度 cellWidth := colWidth - 4 // 减去左右边距 for j, cell := range row { if j >= numCols { break } // 清理单元格文本(只清理无效字符,保留markdown格式) cleanCell := g.cleanTextPreservingMarkdown(cell) // 使用SplitText准确计算需要的行数 var lines []string if chineseFontAvailable { // 对于中文字体,使用SplitText lines = pdf.SplitText(cleanCell, cellWidth) } else { // 对于Arial字体,如果包含中文可能失败,使用估算 charCount := len([]rune(cleanCell)) if charCount == 0 { lines = []string{""} } else { // 中文字符宽度大约是英文字符的2倍 estimatedWidth := 0.0 for _, r := range cleanCell { if r >= 0x4E00 && r <= 0x9FFF { estimatedWidth += 6.0 // 中文字符宽度 } else { estimatedWidth += 3.0 // 英文字符宽度 } } estimatedLines := math.Ceil(estimatedWidth / cellWidth) if estimatedLines < 1 { estimatedLines = 1 } lines = make([]string, int(estimatedLines)) // 简单分割文本 charsPerLine := int(math.Ceil(float64(charCount) / estimatedLines)) for k := 0; k < int(estimatedLines); k++ { start := k * charsPerLine end := start + charsPerLine if end > charCount { end = charCount } if start < charCount { runes := []rune(cleanCell) if start < len(runes) { if end > len(runes) { end = len(runes) } lines[k] = string(runes[start:end]) } } } } } // 计算单元格高度 numLines := float64(len(lines)) if numLines == 0 { numLines = 1 } cellHeight := numLines * cellLineHt * 1.5 if cellHeight < cellLineHt*1.5 { cellHeight = cellLineHt * 1.5 } if cellHeight > maxCellHeight { maxCellHeight = cellHeight } } // 绘制这一行的所有单元格(左边距是15mm) currentX := 15.0 for j, cell := range row { if j >= numCols { break } // 绘制单元格边框和背景 if fill { pdf.SetFillColor(250, 250, 235) // 稍深的米色 } else { pdf.SetFillColor(255, 255, 255) } pdf.Rect(currentX, startY, colWidth, maxCellHeight, "FD") // 绘制文本(使用MultiCell支持换行) pdf.SetTextColor(0, 0, 0) // 确保深黑色 // 只清理无效字符,保留markdown格式 cleanCell := g.cleanTextPreservingMarkdown(cell) // 设置到单元格内,留出边距(每个单元格都从同一行开始) pdf.SetXY(currentX+2, startY+2) // 使用MultiCell自动换行,左对齐 if chineseFontAvailable { pdf.SetFont("ChineseFont", "", 9) } else { pdf.SetFont("Arial", "", 9) } // 使用MultiCell,会自动换行(使用统一的行高) // 限制高度,避免超出单元格 pdf.MultiCell(colWidth-4, cellLineHt*1.5, cleanCell, "", "L", false) // MultiCell后Y坐标已经改变,必须重置以便下一列从同一行开始 // 这是关键:确保所有列都从同一个startY开始 pdf.SetXY(currentX+colWidth, startY) // 移动到下一列 currentX += colWidth } // 移动到下一行的起始位置(使用计算好的最大高度) pdf.SetXY(15.0, startY+maxCellHeight) } } // calculateCellHeight 计算单元格高度(考虑换行) func (g *PDFGenerator) calculateCellHeight(pdf *gofpdf.Fpdf, text string, width, lineHeight float64) float64 { // 移除中文字符避免Arial字体处理时panic // 只保留ASCII字符和常见符号 safeText := g.removeNonASCII(text) if safeText == "" { // 如果全部是中文,使用一个估算值 // 中文字符通常比英文字符宽,按每行30个字符估算 charCount := len([]rune(text)) estimatedLines := (charCount / 30) + 1 if estimatedLines < 1 { estimatedLines = 1 } return float64(estimatedLines) * lineHeight } // 安全地调用SplitText defer func() { if r := recover(); r != nil { g.logger.Warn("SplitText失败,使用估算高度", zap.Any("error", r)) } }() lines := pdf.SplitText(safeText, width) if len(lines) == 0 { return lineHeight } return float64(len(lines)) * lineHeight } // removeNonASCII 移除非ASCII字符(保留ASCII字符和常见符号) func (g *PDFGenerator) removeNonASCII(text string) string { var result strings.Builder for _, r := range text { // 保留ASCII字符(0-127) if r < 128 { result.WriteRune(r) } else { // 中文字符替换为空格或跳过 result.WriteRune(' ') } } return result.String() } // parseMarkdownTable 解析Markdown表格(支持|分隔和空格分隔) func (g *PDFGenerator) parseMarkdownTable(text string) [][]string { lines := strings.Split(text, "\n") var table [][]string var header []string inTable := false usePipeDelimiter := false // 先检查是否使用 | 分隔符 for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if strings.Contains(line, "|") { usePipeDelimiter = true break } } // 记录解析开始 g.logger.Info("开始解析markdown表格", zap.Int("total_lines", len(lines)), zap.Bool("use_pipe_delimiter", usePipeDelimiter)) nonTableLineCount := 0 // 连续非表格行计数(不包括空行) maxNonTableLines := 10 // 最多允许10个连续非表格行(增加容忍度) for _, line := range lines { line = strings.TrimSpace(line) // 检查是否是明确的结束标记 if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") || strings.HasPrefix(line, "####") { // 如果遇到代码块或新的标题,停止解析 if inTable { break } continue } if line == "" { // 空行不影响非表格行计数,继续 continue } var cells []string if usePipeDelimiter { // 使用 | 分隔符的表格 if !strings.Contains(line, "|") { // 如果已经在表格中,遇到非表格行 if inTable { nonTableLineCount++ // 如果连续非表格行过多,可能表格已结束 if nonTableLineCount > maxNonTableLines { // 但先检查后面是否还有表格行 hasMoreTableRows := false for j := len(lines) - 1; j > 0 && j > len(lines)-20; j-- { if strings.Contains(strings.TrimSpace(lines[j]), "|") { hasMoreTableRows = true break } } if !hasMoreTableRows { break } // 如果后面还有表格行,继续解析 nonTableLineCount = 0 } continue } // 如果还没开始表格,跳过非表格行 continue } // 重置非表格行计数(遇到表格行了) nonTableLineCount = 0 // 跳过分隔行(markdown表格的分隔行,如 |---|---| 或 |----------|----------|) // 检查是否是分隔行:只包含 |、-、:、空格,且至少包含一个- trimmedLineForCheck := strings.TrimSpace(line) isSeparator := false if strings.Contains(trimmedLineForCheck, "-") { isSeparator = true for _, r := range trimmedLineForCheck { if r != '|' && r != '-' && r != ':' && r != ' ' { isSeparator = false break } } } if isSeparator { // 这是分隔行,跳过(不管是否已有表头) // 但如果还没有表头,这可能表示表头在下一行 if !inTable { // 跳过分隔行,等待真正的表头 continue } // 如果已经有表头,这可能是格式错误,但继续解析(不停止) continue } cells = strings.Split(line, "|") // 清理首尾空元素 if len(cells) > 0 && cells[0] == "" { cells = cells[1:] } if len(cells) > 0 && cells[len(cells)-1] == "" { cells = cells[:len(cells)-1] } // 验证单元格数量:如果已经有表头,数据行的列数应该与表头一致(允许少量差异) // 但不要因为列数不一致就停止,而是调整列数以匹配表头 if inTable && len(header) > 0 && len(cells) > 0 { // 如果列数不一致,调整以匹配表头 if len(cells) < len(header) { // 如果数据行列数少于表头,补齐空单元格 for len(cells) < len(header) { cells = append(cells, "") } } else if len(cells) > len(header) { // 如果数据行列数多于表头,截断(但记录警告) if len(cells)-len(header) > 3 { g.logger.Warn("表格列数差异较大,截断多余列", zap.Int("header_cols", len(header)), zap.Int("row_cols", len(cells))) } cells = cells[:len(header)] } } } else { // 使用空格/制表符分隔的表格 // 先尝试识别是否是表头行(中文表头,如"字段名类型说明") if strings.ContainsAny(line, "字段类型说明") && !strings.Contains(line, " ") { // 可能是连在一起的中文表头,需要手动分割 if strings.Contains(line, "字段名") { cells = []string{"字段名", "类型", "说明"} } else if strings.Contains(line, "字段") { cells = []string{"字段", "类型", "说明"} } else { // 尝试智能分割 fields := strings.Fields(line) cells = fields } } else { // 尝试按多个连续空格或制表符分割 fields := strings.Fields(line) if len(fields) >= 2 { // 至少有两列,尝试智能分割 // 识别:字段名、类型、说明 fieldName := "" fieldType := "" description := "" typeKeywords := []string{"object", "Object", "string", "String", "int", "Int", "bool", "Bool", "array", "Array", "number", "Number"} // 第一个字段通常是字段名 fieldName = fields[0] // 查找类型字段 for i := 1; i < len(fields); i++ { isType := false for _, kw := range typeKeywords { if fields[i] == kw || strings.EqualFold(fields[i], kw) { fieldType = fields[i] // 剩余字段作为说明 if i+1 < len(fields) { description = strings.Join(fields[i+1:], " ") } isType = true break } } if isType { break } // 如果第二个字段看起来像类型(较短且是已知关键词的一部分) if i == 1 && len(fields[i]) <= 10 { fieldType = fields[i] if i+1 < len(fields) { description = strings.Join(fields[i+1:], " ") } break } } // 如果没找到类型,假设第二个字段是类型 if fieldType == "" && len(fields) >= 2 { fieldType = fields[1] if len(fields) > 2 { description = strings.Join(fields[2:], " ") } } // 构建单元格数组 cells = []string{fieldName} if fieldType != "" { cells = append(cells, fieldType) } if description != "" { cells = append(cells, description) } else if len(fields) > 2 { // 如果说明为空但还有更多字段,合并所有剩余字段 cells = append(cells, strings.Join(fields[2:], " ")) } } else if len(fields) == 1 { // 单列,可能是标题行或分隔 continue } else { continue } } } // 清理每个单元格(保留markdown格式,只清理HTML和无效字符) cleanedCells := make([]string, 0, len(cells)) for _, cell := range cells { cell = strings.TrimSpace(cell) // 先清理HTML标签,但保留markdown格式(如**粗体**、*斜体*等) cell = g.stripHTML(cell) // 清理无效字符,但保留markdown语法字符(*、_、`、[]、()等) cell = g.cleanTextPreservingMarkdown(cell) cleanedCells = append(cleanedCells, cell) } // 检查这一行是否包含有效内容(至少有一个非空单元格) hasValidContent := false for _, cell := range cleanedCells { if strings.TrimSpace(cell) != "" { hasValidContent = true break } } // 如果这一行完全没有有效内容,跳过(但不停止解析) if !hasValidContent { // 空行不应该停止解析,继续查找下一行 continue } // 确保cleanedCells至少有1列(即使只有1列,也可能是有效数据) if len(cleanedCells) == 0 { continue } // 支持单列表格和多列表格(至少1列) if len(cleanedCells) >= 1 { if !inTable { // 第一行作为表头 header = cleanedCells // 如果表头只有1列,保持单列;如果2列,补齐为3列(字段名、类型、说明) if len(header) == 2 { header = append(header, "说明") } table = append(table, header) inTable = true g.logger.Debug("添加表头", zap.Int("cols", len(header)), zap.Strings("header", header)) } else { // 数据行,确保列数与表头一致 row := make([]string, len(header)) for i := range row { if i < len(cleanedCells) { row[i] = cleanedCells[i] } else { row[i] = "" } } table = append(table, row) // 记录第一列内容用于调试 firstCellPreview := "" if len(row) > 0 { firstCellPreview = row[0] if len(firstCellPreview) > 20 { firstCellPreview = firstCellPreview[:20] + "..." } } g.logger.Debug("添加数据行", zap.Int("row_num", len(table)-1), zap.Int("cols", len(row)), zap.String("first_cell", firstCellPreview)) } } else if inTable && len(cleanedCells) > 0 { // 如果已经在表格中,但这一行列数不够,可能是说明行,合并到上一行 if len(table) > 0 { lastRow := table[len(table)-1] if len(lastRow) > 0 { // 将内容追加到最后一列的说明中 lastRow[len(lastRow)-1] += " " + strings.Join(cleanedCells, " ") table[len(table)-1] = lastRow } } } // 注意:不再因为不符合表格格式就停止解析,继续查找可能的表格行 } // 记录解析结果 g.logger.Info("表格解析完成", zap.Int("table_rows", len(table)), zap.Bool("has_header", len(table) > 0), zap.Int("data_rows", len(table)-1)) // 放宽验证条件:至少需要表头(允许只有表头的情况,或者表头+数据行) if len(table) < 1 { g.logger.Warn("表格数据不足,至少需要表头", zap.Int("rows", len(table))) return nil } // 如果只有表头没有数据行,也认为是有效表格(可能是单行表格) if len(table) == 1 { g.logger.Info("表格只有表头,没有数据行", zap.Int("header_cols", len(table[0]))) // 仍然返回,让渲染函数处理 } // 记录表头信息 if len(table) > 0 { g.logger.Info("表格表头", zap.Int("header_cols", len(table[0])), zap.Strings("header", table[0])) } return table } // cleanText 清理文本中的无效字符和乱码 func (g *PDFGenerator) cleanText(text string) string { // 先解码HTML实体 text = html.UnescapeString(text) // 移除或替换无效的UTF-8字符 var result strings.Builder for _, r := range text { // 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等 if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围 (r >= 0x3400 && r <= 0x4DBF) || // 扩展A (r >= 0x20000 && r <= 0x2A6DF) || // 扩展B (r >= 'A' && r <= 'Z') || // 大写字母 (r >= 'a' && r <= 'z') || // 小写字母 (r >= '0' && r <= '9') || // 数字 (r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符 (r == '\n' || r == '\r' || r == '\t') || // 换行和制表符 (r >= 0x3000 && r <= 0x303F) || // CJK符号和标点 (r >= 0xFF00 && r <= 0xFFEF) { // 全角字符 result.WriteRune(r) } else if r > 0x007F && r < 0x00A0 { // 无效的控制字符,替换为空格 result.WriteRune(' ') } // 其他字符(如乱码)直接跳过 } return result.String() } // cleanTextPreservingMarkdown 清理文本但保留markdown语法字符 func (g *PDFGenerator) cleanTextPreservingMarkdown(text string) string { // 先解码HTML实体 text = html.UnescapeString(text) // 移除或替换无效的UTF-8字符,但保留markdown语法字符 var result strings.Builder for _, r := range text { // 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等 // 特别保留markdown语法字符:* _ ` [ ] ( ) # - | : ! if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围 (r >= 0x3400 && r <= 0x4DBF) || // 扩展A (r >= 0x20000 && r <= 0x2A6DF) || // 扩展B (r >= 'A' && r <= 'Z') || // 大写字母 (r >= 'a' && r <= 'z') || // 小写字母 (r >= '0' && r <= '9') || // 数字 (r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符(包括markdown语法字符) (r == '\n' || r == '\r' || r == '\t') || // 换行和制表符 (r >= 0x3000 && r <= 0x303F) || // CJK符号和标点 (r >= 0xFF00 && r <= 0xFFEF) { // 全角字符 result.WriteRune(r) } else if r > 0x007F && r < 0x00A0 { // 无效的控制字符,替换为空格 result.WriteRune(' ') } // 其他字符(如乱码)直接跳过 } return result.String() } // removeMarkdownSyntax 移除markdown语法,保留纯文本 func (g *PDFGenerator) removeMarkdownSyntax(text string) string { // 移除粗体标记 **text** 或 __text__ text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1") text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1") // 移除斜体标记 *text* 或 _text_ text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1") text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1") // 移除代码标记 `code` text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1") // 移除链接标记 [text](url) -> text text = regexp.MustCompile(`\[([^\]]+)\]\([^\)]+\)`).ReplaceAllString(text, "$1") // 移除图片标记 ![alt](url) -> alt text = regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`).ReplaceAllString(text, "$1") // 移除标题标记 # text -> text text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") return text } // stripHTML 去除HTML标签 func (g *PDFGenerator) stripHTML(text string) string { // 解码HTML实体 text = html.UnescapeString(text) // 移除HTML标签 re := regexp.MustCompile(`<[^>]+>`) text = re.ReplaceAllString(text, "") // 处理换行 text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "

", "\n") text = strings.ReplaceAll(text, "", "\n") // 清理多余空白 text = strings.TrimSpace(text) lines := strings.Split(text, "\n") var cleanedLines []string for _, line := range lines { line = strings.TrimSpace(line) if line != "" { cleanedLines = append(cleanedLines, line) } } return strings.Join(cleanedLines, "\n") } // formatJSON 格式化JSON字符串以便更好地显示 func (g *PDFGenerator) formatJSON(jsonStr string) (string, error) { var jsonObj interface{} if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err != nil { return jsonStr, err // 如果解析失败,返回原始字符串 } // 重新格式化JSON,使用缩进 formatted, err := json.MarshalIndent(jsonObj, "", " ") if err != nil { return jsonStr, err } return string(formatted), nil } // extractJSON 从文本中提取JSON func (g *PDFGenerator) extractJSON(text string) string { // 查找 ```json 代码块 re := regexp.MustCompile("(?s)```json\\s*\n(.*?)\n```") matches := re.FindStringSubmatch(text) if len(matches) > 1 { return strings.TrimSpace(matches[1]) } // 查找普通代码块 re = regexp.MustCompile("(?s)```\\s*\n(.*?)\n```") matches = re.FindStringSubmatch(text) if len(matches) > 1 { content := strings.TrimSpace(matches[1]) // 检查是否是JSON if strings.HasPrefix(content, "{") || strings.HasPrefix(content, "[") { return content } } return "" } // generateJSONExample 从请求参数表格生成JSON示例 func (g *PDFGenerator) generateJSONExample(requestParams string) string { tableData := g.parseMarkdownTable(requestParams) if len(tableData) < 2 { return "" } // 查找字段名列和类型列 var fieldCol, typeCol int = -1, -1 header := tableData[0] for i, h := range header { hLower := strings.ToLower(h) if strings.Contains(hLower, "字段") || strings.Contains(hLower, "参数") || strings.Contains(hLower, "field") { fieldCol = i } if strings.Contains(hLower, "类型") || strings.Contains(hLower, "type") { typeCol = i } } if fieldCol == -1 { return "" } // 生成JSON结构 jsonMap := make(map[string]interface{}) for i := 1; i < len(tableData); i++ { row := tableData[i] if fieldCol >= len(row) { continue } fieldName := strings.TrimSpace(row[fieldCol]) if fieldName == "" { continue } // 跳过表头行 if strings.Contains(strings.ToLower(fieldName), "字段") || strings.Contains(strings.ToLower(fieldName), "参数") { continue } // 获取类型 fieldType := "string" if typeCol >= 0 && typeCol < len(row) { fieldType = strings.ToLower(strings.TrimSpace(row[typeCol])) } // 设置示例值 var value interface{} if strings.Contains(fieldType, "int") || strings.Contains(fieldType, "number") { value = 0 } else if strings.Contains(fieldType, "bool") { value = true } else if strings.Contains(fieldType, "array") || strings.Contains(fieldType, "list") { value = []interface{}{} } else if strings.Contains(fieldType, "object") || strings.Contains(fieldType, "dict") { value = map[string]interface{}{} } else { // 根据字段名设置合理的示例值 fieldLower := strings.ToLower(fieldName) if strings.Contains(fieldLower, "name") || strings.Contains(fieldName, "姓名") { value = "张三" } else if strings.Contains(fieldLower, "id_card") || strings.Contains(fieldLower, "idcard") || strings.Contains(fieldName, "身份证") { value = "110101199001011234" } else if strings.Contains(fieldLower, "phone") || strings.Contains(fieldLower, "mobile") || strings.Contains(fieldName, "手机") { value = "13800138000" } else if strings.Contains(fieldLower, "card") || strings.Contains(fieldName, "银行卡") { value = "6222021234567890123" } else { value = "string" } } // 处理嵌套字段(如 baseInfo.phone) if strings.Contains(fieldName, ".") { parts := strings.Split(fieldName, ".") current := jsonMap for j := 0; j < len(parts)-1; j++ { if _, ok := current[parts[j]].(map[string]interface{}); !ok { current[parts[j]] = make(map[string]interface{}) } current = current[parts[j]].(map[string]interface{}) } current[parts[len(parts)-1]] = value } else { jsonMap[fieldName] = value } } // 使用encoding/json正确格式化JSON jsonBytes, err := json.MarshalIndent(jsonMap, "", " ") if err != nil { // 如果JSON序列化失败,返回简单的字符串表示 return fmt.Sprintf("%v", jsonMap) } return string(jsonBytes) } // addHeader 添加页眉(logo和文字) func (g *PDFGenerator) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { pdf.SetY(5) // 绘制logo(如果存在) if g.logoPath != "" { if _, err := os.Stat(g.logoPath); err == nil { // gofpdf的ImageOptions方法(调整位置和大小,左边距是15mm) pdf.ImageOptions(g.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "") g.logger.Info("已添加logo", zap.String("path", g.logoPath)) } else { g.logger.Warn("logo文件不存在", zap.String("path", g.logoPath), zap.Error(err)) } } else { g.logger.Warn("logo路径为空") } // 绘制"天远数据"文字(使用中文字体如果可用) pdf.SetXY(33, 8) if chineseFontAvailable { pdf.SetFont("ChineseFont", "B", 14) } else { pdf.SetFont("Arial", "B", 14) } pdf.CellFormat(0, 10, "天远数据", "", 0, "L", false, 0, "") // 绘制下横线(优化位置,左边距是15mm) pdf.Line(15, 22, 75, 22) } // addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) func (g *PDFGenerator) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { // 如果中文字体不可用,跳过水印(避免显示乱码) if !chineseFontAvailable { return } // 保存当前图形状态 pdf.TransformBegin() defer pdf.TransformEnd() // 获取页面尺寸和边距 _, pageHeight := pdf.GetPageSize() leftMargin, topMargin, _, bottomMargin := pdf.GetMargins() // 计算实际可用区域高度 usableHeight := pageHeight - topMargin - bottomMargin // 设置水印样式(使用中文字体) fontSize := 45.0 pdf.SetFont("ChineseFont", "", fontSize) // 设置灰色和透明度(加深水印,使其更明显) pdf.SetTextColor(180, 180, 180) // 深一点的灰色 pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 // 计算文字宽度 textWidth := pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } // 从左边开始,计算起始位置 // 起始X:左边距 // 起始Y:考虑水印文字长度和旋转后需要的空间 startX := leftMargin startY := topMargin + textWidth*0.5 // 为旋转留出空间 // 移动到起始位置 pdf.TransformTranslate(startX, startY) // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度) pdf.TransformRotate(-45, 0, 0) // 检查文字是否会超出可用区域(旋转后的对角线长度) rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize) if rotatedDiagonal > usableHeight*0.8 { // 如果太大,缩小字体 fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal pdf.SetFont("ChineseFont", "", fontSize) textWidth = pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } } // 从左边开始绘制水印文字 pdf.SetXY(0, 0) pdf.CellFormat(textWidth, fontSize, g.watermarkText, "", 0, "L", false, 0, "") // 恢复透明度和颜色 pdf.SetAlpha(1.0, "Normal") pdf.SetTextColor(0, 0, 0) // 恢复为黑色 }