Files
tyapi-server/internal/shared/pdf/pdf_generator.go
2025-12-04 12:56:39 +08:00

2144 lines
63 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "<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) // 恢复为黑色
}