2144 lines
63 KiB
Go
2144 lines
63 KiB
Go
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
|
||
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, "<br>", "\n")
|
||
text = strings.ReplaceAll(text, "<br/>", "\n")
|
||
text = strings.ReplaceAll(text, "</p>", "\n")
|
||
text = strings.ReplaceAll(text, "</div>", "\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) // 恢复为黑色
|
||
}
|